WealthFromAI commited on
Commit
74d5f6e
·
verified ·
1 Parent(s): 9237e12

FORGE-X: Upload source (bf5f8cb4-api.zip)

Browse files
README.md CHANGED
@@ -1,13 +1,98 @@
1
  ---
2
- title: Github Actions Cicd Matching And Recommendation Api Bf5f
3
- emoji: 🦀
4
- colorFrom: yellow
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
 
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: "GitHub Actions & CI/CD Matching and Recommendation API"
3
+ emoji:
4
+ colorFrom: gray
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ tags:
12
+ - github
13
+ - ci-cd
14
+ - automation
15
+ - devops
16
+ - actions
17
+ - data
18
+ - validation
19
+ - enrichment
20
+ - document
21
+ - generation
22
  ---
23
+ # GitHub Actions & CI/CD Matching and Recommendation API
24
 
25
+ Stop wasting hours sifting through thousands of GitHub Actions. Get precise, context-aware recommendations for your CI/CD pipeline in milliseconds.
26
+
27
+ This API solves the painful problem of action discovery and selection by using intelligent matching algorithms that consider your repo's language, framework, and platform. Unlike generic search, it delivers ranked, relevant actions with confidence scores, saving you time and ensuring you use the best tools for your workflow.
28
+
29
+ ## What's Included
30
+
31
+ - Context-aware matching based on your repo's language, framework, and platform
32
+ - Real-time recommendations via a simple REST API endpoint
33
+ - Custom filters for stars, maintenance status, license, and more
34
+ - Seamless integration with existing CI/CD tools and scripts
35
+ - Returns ranked list with confidence scores and metadata for each action
36
+
37
+ ## Who Is This For
38
+
39
+ - Developers building or optimizing CI/CD pipelines on GitHub Actions
40
+ - DevOps engineers seeking to automate workflow configuration
41
+ - Teams migrating from other CI/CD platforms to GitHub Actions
42
+ - Open-source maintainers wanting to standardize their action choices
43
+
44
+ ## How It Works
45
+
46
+ Sign up to get your unique API key. Send a POST request with your workflow requirements (e.g., language, build tool, deployment target). Receive a JSON response with a ranked list of recommended GitHub Actions, including confidence scores, descriptions, and links.
47
+
48
+ ## Frequently Asked Questions
49
+
50
+ **How is the pricing structured?**
51
+ The API costs $42.23 for lifetime access with 10,000 requests per month. No recurring fees.
52
+
53
+ **What are the rate limits?**
54
+ You get 10,000 requests per month, with a burst limit of 100 requests per minute.
55
+
56
+ **Is my data private?**
57
+ Yes, we do not store your workflow details. All requests are processed in memory and discarded immediately.
58
+
59
+ **Can I customize the matching criteria?**
60
+ Absolutely. You can pass optional parameters like minimum stars, last update date, license type, and more.
61
+
62
+ **Does this work with self-hosted runners or enterprise?**
63
+ Yes, the API is platform-agnostic and works with any GitHub Actions setup, including enterprise accounts.
64
+
65
+ ## What You Get
66
+
67
+ - Instant digital download
68
+ - Complete REST API with full documentation
69
+ - Free updates for life — pay once, own forever
70
+ - Setup guide and usage instructions
71
+
72
+ **Get your API key now and start matching the perfect actions for your pipeline.**
73
+
74
+ ## 🚀 Usage
75
+
76
+ 1. Click **Use in Spaces** above to run the demo directly
77
+ 2. Or clone the repository and run locally:
78
+
79
+ ```bash
80
+ git clone https://huggingface.co/spaces/WealthFromAI/github-actions-cicd-matching-and-recommendation-api-bf5f
81
+ cd github-actions-cicd-matching-and-recommendation-api-bf5f
82
+ pip install -r requirements.txt
83
+ python app.py
84
+ ```
85
+
86
+ ## 💰 Pricing
87
+
88
+ - **Demo**: Free on Hugging Face Spaces
89
+ - **Full Source Code**: $42.23
90
+ - Available on [Gumroad](https://gumroad.com) and [Whop](https://whop.com)
91
+
92
+ ## 📄 License
93
+
94
+ MIT License — free to use, modify, and distribute.
95
+
96
+ ---
97
+
98
+ *Built with [FORGE-X](https://github.com/WealthFromAI) — automated digital product engine*
bf5f8cb4/Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 8000
11
+
12
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
13
+ CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
14
+
15
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
bf5f8cb4/README.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub Actions & CI/CD Matching and Recommendation API
2
+
3
+ > AI-powered matching between GitHub Actions & CI/CD entities — clients to services, products to needs. Smart recommendations tuned for your domain.
4
+
5
+ ## Features
6
+
7
+ - Full REST API
8
+
9
+ ## Quick Start
10
+
11
+ ```bash
12
+ # 1. Install dependencies
13
+ pip install -r requirements.txt
14
+
15
+ # 2. Configure environment
16
+ cp .env.example .env
17
+ # Edit .env with your settings
18
+
19
+ # 3. Run locally
20
+ uvicorn main:app --reload --port 8000
21
+
22
+ # 4. View interactive docs
23
+ open http://localhost:8000/docs
24
+ ```
25
+
26
+ ## Docker Deployment
27
+
28
+ ```bash
29
+ # Build and run
30
+ docker compose up -d
31
+
32
+ # Check health
33
+ curl http://localhost:8000/health
34
+ ```
35
+
36
+ ## Authentication
37
+
38
+ Get a token first:
39
+ ```bash
40
+ curl -X POST "http://localhost:8000/auth/token?username=admin&password=admin123"
41
+ ```
42
+
43
+ Use the token in subsequent requests:
44
+ ```bash
45
+ curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/items
46
+ ```
47
+
48
+ ## API Endpoints
49
+
50
+ | Method | Path | Description |
51
+ |--------|------|-------------|
52
+ | GET | `/health` | System health |
53
+ | POST | `/auth/token` | Get JWT token |
54
+ | GET | `/items` | List all items |
55
+ | POST | `/items` | Create item |
56
+ | GET | `/items/{id}` | Get item |
57
+ | PATCH | `/items/{id}` | Update item |
58
+ | DELETE | `/items/{id}` | Delete item |
59
+ | GET | `/stats` | API statistics |
60
+
61
+ Full interactive docs: `http://localhost:8000/docs`
62
+
63
+ ## Rate Limits
64
+
65
+ | Endpoint | Limit |
66
+ |----------|-------|
67
+ | `/auth/token` | 10/minute |
68
+ | `GET /items` | 60/minute |
69
+ | `POST /items` | 30/minute |
70
+ | `DELETE /items` | 20/minute |
71
+
72
+ ## Running Tests
73
+
74
+ ```bash
75
+ pip install pytest httpx
76
+ pytest tests/ -v
77
+ ```
78
+
79
+ ## Production Notes
80
+
81
+ - Change `SECRET_KEY` in `.env` before deploying
82
+ - Replace in-memory `_db` with a real database
83
+ - Add proper user management to `auth.py`
84
+ - Configure `ALLOWED_ORIGINS` for CORS
85
+ - Use Nginx/Traefik as reverse proxy
86
+
87
+ ## License
88
+
89
+ MIT
bf5f8cb4/auth.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simple JWT auth — swap for OAuth2/API keys in production.
3
+ """
4
+ import os
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Optional
7
+ from fastapi import Depends, HTTPException, status
8
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
9
+ from jose import JWTError, jwt
10
+ from passlib.context import CryptContext
11
+
12
+ SECRET_KEY = os.getenv("SECRET_KEY", "changeme-in-production-please")
13
+ ALGORITHM = os.getenv("ALGORITHM", "HS256")
14
+ EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
15
+
16
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
17
+ security = HTTPBearer()
18
+
19
+ # Demo users — replace with real DB lookup
20
+ USERS: dict[str, str] = {
21
+ "admin": pwd_context.hash("admin123"),
22
+ "demo": pwd_context.hash("demo123"),
23
+ }
24
+
25
+
26
+ def verify_password(username: str, password: str) -> bool:
27
+ hashed = USERS.get(username)
28
+ if not hashed:
29
+ return False
30
+ return pwd_context.verify(password, hashed)
31
+
32
+
33
+ def create_access_token(data: dict) -> str:
34
+ to_encode = data.copy()
35
+ expire = datetime.now(timezone.utc) + timedelta(minutes=EXPIRE_MINUTES)
36
+ to_encode["exp"] = expire
37
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
38
+
39
+
40
+ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
41
+ token = credentials.credentials
42
+ try:
43
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
44
+ username: Optional[str] = payload.get("sub")
45
+ if not username:
46
+ raise HTTPException(status_code=401, detail="Invalid token payload")
47
+ return username
48
+ except JWTError:
49
+ raise HTTPException(
50
+ status_code=status.HTTP_401_UNAUTHORIZED,
51
+ detail="Invalid or expired token",
52
+ headers={"WWW-Authenticate": "Bearer"},
53
+ )
bf5f8cb4/docker-compose.yml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+ services:
3
+ api:
4
+ build: .
5
+ ports:
6
+ - "8000:8000"
7
+ env_file: .env
8
+ restart: unless-stopped
9
+ volumes:
10
+ - ./data:/app/data
bf5f8cb4/main.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GitHub Actions & CI/CD Matching and Recommendation API — REST API Service
3
+ AI-powered matching between GitHub Actions & CI/CD entities — clients to services, products to needs. Smart recommendations tuned for your domain.
4
+
5
+ Features:
6
+
7
+ """
8
+ import os
9
+ from contextlib import asynccontextmanager
10
+ from typing import Optional, List
11
+ from datetime import datetime, timezone
12
+
13
+ from fastapi import FastAPI, Depends, HTTPException, Request, status
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import JSONResponse
16
+ from slowapi import Limiter
17
+ from slowapi.util import get_remote_address
18
+ from slowapi.errors import RateLimitExceeded
19
+ from dotenv import load_dotenv
20
+
21
+ from models import ItemCreate, ItemUpdate, ItemResponse, HealthResponse
22
+ from auth import get_current_user, create_access_token, verify_password
23
+
24
+ load_dotenv()
25
+
26
+ limiter = Limiter(key_func=get_remote_address)
27
+ _db: dict = {} # In-memory store — swap for real DB in production
28
+
29
+
30
+ @asynccontextmanager
31
+ async def lifespan(app: FastAPI):
32
+ print(f"{app.title} starting up...")
33
+ yield
34
+ print(f"{app.title} shutting down...")
35
+
36
+
37
+ app = FastAPI(
38
+ title="GitHub Actions & CI/CD Matching and Recommendation API",
39
+ description="AI-powered matching between GitHub Actions & CI/CD entities — clients to services, products to needs. Smart recommendations tuned for your domain.",
40
+ version="1.0.0",
41
+ lifespan=lifespan,
42
+ docs_url="/docs",
43
+ redoc_url="/redoc",
44
+ openapi_url="/openapi.json",
45
+ )
46
+
47
+ app.state.limiter = limiter
48
+
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
52
+ allow_credentials=True,
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+
58
+ @app.exception_handler(RateLimitExceeded)
59
+ async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
60
+ return JSONResponse(
61
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
62
+ content={"error": "Rate limit exceeded", "detail": str(exc)},
63
+ )
64
+
65
+
66
+ @app.exception_handler(Exception)
67
+ async def global_exception_handler(request: Request, exc: Exception):
68
+ return JSONResponse(
69
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
70
+ content={"error": "Internal server error", "detail": str(exc)},
71
+ )
72
+
73
+
74
+ # ── Health ────────────────────────────────────────────────────────────────────
75
+
76
+ @app.get("/health", response_model=HealthResponse, tags=["System"])
77
+ async def health():
78
+ return {
79
+ "status": "healthy",
80
+ "service": "GitHub Actions & CI/CD Matching and Recommendation API",
81
+ "version": "1.0.0",
82
+ "timestamp": datetime.now(timezone.utc).isoformat(),
83
+ "items_count": len(_db),
84
+ }
85
+
86
+
87
+ # ── Auth ──────────────────────────────────────────────────────────────────────
88
+
89
+ @app.post("/auth/token", tags=["Auth"], summary="Get API token")
90
+ @limiter.limit("10/minute")
91
+ async def login(request: Request, username: str, password: str):
92
+ if not verify_password(username, password):
93
+ raise HTTPException(status_code=401, detail="Invalid credentials")
94
+ token = create_access_token({"sub": username})
95
+ return {"access_token": token, "token_type": "bearer"}
96
+
97
+
98
+ # ── CRUD ──────────────────────────────────────────────────────────────────────
99
+
100
+ @app.get("/items", response_model=List[ItemResponse], tags=["Items"])
101
+ @limiter.limit("60/minute")
102
+ async def list_items(
103
+ request: Request,
104
+ skip: int = 0,
105
+ limit: int = 50,
106
+ search: Optional[str] = None,
107
+ current_user: str = Depends(get_current_user),
108
+ ):
109
+ items = list(_db.values())
110
+ if search:
111
+ items = [i for i in items if search.lower() in i.get("name", "").lower()]
112
+ return items[skip : skip + limit]
113
+
114
+
115
+ @app.post("/items", response_model=ItemResponse, status_code=201, tags=["Items"])
116
+ @limiter.limit("30/minute")
117
+ async def create_item(
118
+ request: Request,
119
+ item: ItemCreate,
120
+ current_user: str = Depends(get_current_user),
121
+ ):
122
+ item_id = f"item_{len(_db) + 1:06d}"
123
+ record = {
124
+ "id": item_id,
125
+ **item.model_dump(),
126
+ "created_by": current_user,
127
+ "created_at": datetime.now(timezone.utc).isoformat(),
128
+ "updated_at": datetime.now(timezone.utc).isoformat(),
129
+ }
130
+ _db[item_id] = record
131
+ return record
132
+
133
+
134
+ @app.get("/items/{item_id}", response_model=ItemResponse, tags=["Items"])
135
+ @limiter.limit("120/minute")
136
+ async def get_item(
137
+ request: Request,
138
+ item_id: str,
139
+ current_user: str = Depends(get_current_user),
140
+ ):
141
+ if item_id not in _db:
142
+ raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
143
+ return _db[item_id]
144
+
145
+
146
+ @app.patch("/items/{item_id}", response_model=ItemResponse, tags=["Items"])
147
+ @limiter.limit("30/minute")
148
+ async def update_item(
149
+ request: Request,
150
+ item_id: str,
151
+ item: ItemUpdate,
152
+ current_user: str = Depends(get_current_user),
153
+ ):
154
+ if item_id not in _db:
155
+ raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
156
+ record = _db[item_id]
157
+ updates = item.model_dump(exclude_unset=True)
158
+ record.update({**updates, "updated_at": datetime.now(timezone.utc).isoformat()})
159
+ _db[item_id] = record
160
+ return record
161
+
162
+
163
+ @app.delete("/items/{item_id}", status_code=204, tags=["Items"])
164
+ @limiter.limit("20/minute")
165
+ async def delete_item(
166
+ request: Request,
167
+ item_id: str,
168
+ current_user: str = Depends(get_current_user),
169
+ ):
170
+ if item_id not in _db:
171
+ raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
172
+ del _db[item_id]
173
+
174
+
175
+ # ── Stats ─────────────────────────────────────────────────────────────────────
176
+
177
+ @app.get("/stats", tags=["System"])
178
+ @limiter.limit("30/minute")
179
+ async def stats(request: Request, current_user: str = Depends(get_current_user)):
180
+ return {
181
+ "total_items": len(_db),
182
+ "service": "GitHub Actions & CI/CD Matching and Recommendation API",
183
+ "niche": "github_actions",
184
+ }
bf5f8cb4/models.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class ItemCreate(BaseModel):
7
+ name: str = Field(..., min_length=1, max_length=200, description="Item name")
8
+ description: Optional[str] = Field(None, max_length=2000)
9
+ category: Optional[str] = Field(None, max_length=100)
10
+ tags: list[str] = Field(default_factory=list)
11
+ metadata: dict = Field(default_factory=dict)
12
+
13
+ model_config = {"json_schema_extra": {"example": {
14
+ "name": "Sample Item",
15
+ "description": "A sample github_actions item",
16
+ "category": "default",
17
+ "tags": ["github_actions"],
18
+ "metadata": {},
19
+ }}}
20
+
21
+
22
+ class ItemUpdate(BaseModel):
23
+ name: Optional[str] = Field(None, min_length=1, max_length=200)
24
+ description: Optional[str] = Field(None, max_length=2000)
25
+ category: Optional[str] = None
26
+ tags: Optional[list[str]] = None
27
+ metadata: Optional[dict] = None
28
+
29
+
30
+ class ItemResponse(BaseModel):
31
+ id: str
32
+ name: str
33
+ description: Optional[str]
34
+ category: Optional[str]
35
+ tags: list[str]
36
+ metadata: dict
37
+ created_by: str
38
+ created_at: str
39
+ updated_at: str
40
+
41
+
42
+ class HealthResponse(BaseModel):
43
+ status: str
44
+ service: str
45
+ version: str
46
+ timestamp: str
47
+ items_count: int
bf5f8cb4/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.27.0
3
+ pydantic>=2.0.0
4
+ python-jose[cryptography]>=3.3.0
5
+ passlib[bcrypt]>=1.7.4
6
+ python-multipart>=0.0.9
7
+ slowapi>=0.1.9
8
+ python-dotenv>=1.0.0
9
+ httpx>=0.27.0
bf5f8cb4/tests/__init__.py ADDED
File without changes
bf5f8cb4/tests/test_main.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for GitHub Actions & CI/CD Matching and Recommendation API API.
3
+ """
4
+ import pytest
5
+ from fastapi.testclient import TestClient
6
+ from main import app
7
+
8
+ client = TestClient(app)
9
+
10
+
11
+ def test_health():
12
+ r = client.get("/health")
13
+ assert r.status_code == 200
14
+ data = r.json()
15
+ assert data["status"] == "healthy"
16
+ assert "version" in data
17
+
18
+
19
+ def test_unauthorized():
20
+ r = client.get("/items")
21
+ assert r.status_code == 403
22
+
23
+
24
+ def test_login():
25
+ r = client.post("/auth/token", params={"username": "admin", "password": "admin123"})
26
+ assert r.status_code == 200
27
+ assert "access_token" in r.json()
28
+
29
+
30
+ def _auth_headers():
31
+ r = client.post("/auth/token", params={"username": "admin", "password": "admin123"})
32
+ token = r.json()["access_token"]
33
+ return {"Authorization": f"Bearer {token}"}
34
+
35
+
36
+ def test_create_item():
37
+ headers = _auth_headers()
38
+ payload = {"name": "Test Item", "description": "A test", "tags": ["test"]}
39
+ r = client.post("/items", json=payload, headers=headers)
40
+ assert r.status_code == 201
41
+ data = r.json()
42
+ assert data["name"] == "Test Item"
43
+ assert "id" in data
44
+
45
+
46
+ def test_list_items():
47
+ headers = _auth_headers()
48
+ r = client.get("/items", headers=headers)
49
+ assert r.status_code == 200
50
+ assert isinstance(r.json(), list)
51
+
52
+
53
+ def test_get_item():
54
+ headers = _auth_headers()
55
+ # Create
56
+ payload = {"name": "Get Test", "tags": []}
57
+ r = client.post("/items", json=payload, headers=headers)
58
+ item_id = r.json()["id"]
59
+ # Get
60
+ r = client.get(f"/items/{item_id}", headers=headers)
61
+ assert r.status_code == 200
62
+ assert r.json()["id"] == item_id
63
+
64
+
65
+ def test_update_item():
66
+ headers = _auth_headers()
67
+ payload = {"name": "Update Test", "tags": []}
68
+ r = client.post("/items", json=payload, headers=headers)
69
+ item_id = r.json()["id"]
70
+ r = client.patch(f"/items/{item_id}", json={"name": "Updated"}, headers=headers)
71
+ assert r.status_code == 200
72
+ assert r.json()["name"] == "Updated"
73
+
74
+
75
+ def test_delete_item():
76
+ headers = _auth_headers()
77
+ payload = {"name": "Delete Test", "tags": []}
78
+ r = client.post("/items", json=payload, headers=headers)
79
+ item_id = r.json()["id"]
80
+ r = client.delete(f"/items/{item_id}", headers=headers)
81
+ assert r.status_code == 204
82
+ r = client.get(f"/items/{item_id}", headers=headers)
83
+ assert r.status_code == 404
84
+
85
+
86
+ def test_stats():
87
+ headers = _auth_headers()
88
+ r = client.get("/stats", headers=headers)
89
+ assert r.status_code == 200
90
+ assert "total_items" in r.json()
91
+
92
+
93
+ def test_openapi_docs():
94
+ r = client.get("/openapi.json")
95
+ assert r.status_code == 200
96
+ assert "openapi" in r.json()