Compare commits
9 Commits
849a65d4d9
...
66074a430d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66074a430d | ||
|
|
abe9fc166b | ||
|
|
ea198e8b0d | ||
|
|
60d9228d91 | ||
|
|
28fde3627e | ||
|
|
aece120017 | ||
|
|
715536033b | ||
|
|
3f0f77cc23 | ||
|
|
75f40acb17 |
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ===========================================
|
||||||
|
# OpenRouter API Key Monitor - Configuration
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite:///./data/app.db
|
||||||
|
|
||||||
|
# Security - REQUIRED
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
SECRET_KEY=your-super-secret-jwt-key-min-32-chars
|
||||||
|
ENCRYPTION_KEY=your-32-byte-encryption-key-here
|
||||||
|
|
||||||
|
# OpenRouter Integration
|
||||||
|
OPENROUTER_API_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
|
# Background Tasks
|
||||||
|
SYNC_INTERVAL_MINUTES=60
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
MAX_API_KEYS_PER_USER=10
|
||||||
|
RATE_LIMIT_REQUESTS=100
|
||||||
|
RATE_LIMIT_WINDOW=3600
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_EXPIRATION_HOURS=24
|
||||||
|
|
||||||
|
# Development
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# ===========================================
|
||||||
|
# OpenRouter API Key Monitor - .gitignore
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Data directory (for local development)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic/versions/*.py
|
||||||
|
!alembic/versions/.gitkeep
|
||||||
164
.opencode/WORKFLOW.md
Normal file
164
.opencode/WORKFLOW.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Flusso di Lavoro Obbligatorio - getNotebooklmPower
|
||||||
|
|
||||||
|
> **Regola fondamentale:** *Safety first, little often, double check*
|
||||||
|
|
||||||
|
## 1. Contesto (Prima di ogni task)
|
||||||
|
|
||||||
|
**OBBLIGATORIO:** Prima di implementare qualsiasi funzionalità:
|
||||||
|
|
||||||
|
1. **Leggi il PRD**: Leggi sempre `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/prd.md` per capire i requisiti del task corrente
|
||||||
|
2. **Non implementare mai funzionalità non esplicitamente richieste**
|
||||||
|
3. **Scope check**: Verifica che il task rientri nello scope definito nel PRD
|
||||||
|
|
||||||
|
## 2. TDD (Test-Driven Development)
|
||||||
|
|
||||||
|
**Ciclo RED → GREEN → REFACTOR:**
|
||||||
|
|
||||||
|
1. **RED**: Scrivi PRIMA il test fallimentare per la singola funzionalità
|
||||||
|
2. **GREEN**: Scrivi il codice applicativo minimo necessario per far passare il test
|
||||||
|
3. **REFACTOR**: Migliora il codice mantenendo i test verdi
|
||||||
|
4. **Itera** finché la funzionalità non è completa e tutti i test passano
|
||||||
|
|
||||||
|
**Regole TDD:**
|
||||||
|
- Un test per singolo comportamento
|
||||||
|
- Testare prima i casi limite (errori, input invalidi)
|
||||||
|
- Coverage target: ≥90%
|
||||||
|
- Usa AAA pattern: Arrange → Act → Assert
|
||||||
|
|
||||||
|
## 3. Memoria e Logging
|
||||||
|
|
||||||
|
**Documentazione obbligatoria:**
|
||||||
|
|
||||||
|
| Evento | Azione | File |
|
||||||
|
|--------|--------|------|
|
||||||
|
| Bug complesso risolto | Descrivi il bug e la soluzione | `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/docs/bug_ledger.md` |
|
||||||
|
| Decisione di design | Documenta il pattern scelto | `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/docs/architecture.md` |
|
||||||
|
| Cambio architetturale | Aggiorna le scelte architetturali | `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/docs/architecture.md` |
|
||||||
|
| Inizio task | Aggiorna progresso corrente | `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/progress.md` |
|
||||||
|
| Fine task | Registra completamento | `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/progress.md` |
|
||||||
|
| Blocco riscontrato | Documenta problema e soluzione | `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/progress.md` |
|
||||||
|
|
||||||
|
**Formato bug_ledger.md:**
|
||||||
|
```markdown
|
||||||
|
## YYYY-MM-DD: [Titolo Bug]
|
||||||
|
**Sintomo:** [Descrizione sintomo]
|
||||||
|
**Causa:** [Root cause]
|
||||||
|
**Soluzione:** [Fix applicato]
|
||||||
|
**Prevenzione:** [Come evitare in futuro]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Git Flow (Commit)
|
||||||
|
|
||||||
|
**Alla fine di ogni task completato con test verdi:**
|
||||||
|
|
||||||
|
1. **Commit atomico**: Un commit per singola modifica funzionale
|
||||||
|
2. **Conventional Commits** obbligatorio:
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
3. **Tipi ammessi:**
|
||||||
|
- `feat:` - Nuova funzionalità
|
||||||
|
- `fix:` - Correzione bug
|
||||||
|
- `docs:` - Documentazione
|
||||||
|
- `test:` - Test
|
||||||
|
- `refactor:` - Refactoring
|
||||||
|
- `chore:` - Manutenzione
|
||||||
|
4. **Scope**: api, webhook, skill, notebook, source, artifact, auth, core
|
||||||
|
5. **Documenta il commit**: Aggiorna `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/githistory.md` con contesto e spiegazione
|
||||||
|
|
||||||
|
**Esempi:**
|
||||||
|
```bash
|
||||||
|
feat(api): add notebook creation endpoint
|
||||||
|
|
||||||
|
- Implements POST /api/v1/notebooks
|
||||||
|
- Validates title length (max 100 chars)
|
||||||
|
- Returns 201 with notebook details
|
||||||
|
|
||||||
|
Closes #123
|
||||||
|
```
|
||||||
|
|
||||||
|
**Formato githistory.md:**
|
||||||
|
```markdown
|
||||||
|
## 2026-04-05 14:30 - feat(api): add notebook creation endpoint
|
||||||
|
|
||||||
|
**Hash:** `a1b2c3d`
|
||||||
|
**Autore:** @tdd-developer
|
||||||
|
**Branch:** main
|
||||||
|
|
||||||
|
### Contesto
|
||||||
|
Necessità di creare notebook programmaticamente via API per integrazione con altri agenti.
|
||||||
|
|
||||||
|
### Cosa cambia
|
||||||
|
- Aggiunto endpoint POST /api/v1/notebooks
|
||||||
|
- Implementata validazione titolo (max 100 chars)
|
||||||
|
- Aggiunto test coverage 95%
|
||||||
|
|
||||||
|
### Perché
|
||||||
|
Il PRD richiede CRUD operations su notebook. Questo è il primo endpoint implementato.
|
||||||
|
|
||||||
|
### Impatto
|
||||||
|
- [x] Nuova feature
|
||||||
|
- [ ] Breaking change
|
||||||
|
- [ ] Modifica API
|
||||||
|
|
||||||
|
### File modificati
|
||||||
|
- src/api/routes/notebooks.py - Nuovo endpoint
|
||||||
|
- src/services/notebook_service.py - Logica creazione
|
||||||
|
- tests/unit/test_notebook_service.py - Test unitari
|
||||||
|
|
||||||
|
### Note
|
||||||
|
Closes #42
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Spec-Driven Development (SDD)
|
||||||
|
|
||||||
|
**Prima di scrivere codice, definisci le specifiche:**
|
||||||
|
|
||||||
|
### 5.1 Analisi Profonda
|
||||||
|
- Fai domande mirate per chiarire dubbi architetturali o di business
|
||||||
|
- Non procedere con specifiche vaghe
|
||||||
|
- Verifica vincoli tecnici e dipendenze
|
||||||
|
|
||||||
|
### 5.2 Output Richiesti (cartella `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/`)
|
||||||
|
|
||||||
|
Tutto il lavoro di specifica si concretizza in questi file:
|
||||||
|
|
||||||
|
| File | Contenuto |
|
||||||
|
|------|-----------|
|
||||||
|
| `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/prd.md` | Product Requirements Document (obiettivi, user stories, requisiti tecnici) |
|
||||||
|
| `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/architecture.md` | Scelte architetturali, stack tecnologico, diagrammi di flusso |
|
||||||
|
| `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/kanban.md` | Scomposizione in task minimi e verificabili (regola "little often") |
|
||||||
|
|
||||||
|
### 5.3 Principio "Little Often"
|
||||||
|
- Scomporre in task il più piccoli possibile
|
||||||
|
- Ogni task deve essere verificabile in modo indipendente
|
||||||
|
- Progresso incrementale, mai "big bang"
|
||||||
|
|
||||||
|
### 5.4 Rigore
|
||||||
|
- **Sii diretto, conciso e tecnico**
|
||||||
|
- **Se una richiesta è vaga, non inventare: chiedi di precisare**
|
||||||
|
- Nessuna supposizione non verificata
|
||||||
|
|
||||||
|
## Checklist Pre-Implementazione
|
||||||
|
|
||||||
|
- [ ] Ho letto il PRD in `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/prd.md`
|
||||||
|
- [ ] Ho compreso lo scope del task
|
||||||
|
- [ ] Ho scritto il test fallimentare (RED)
|
||||||
|
- [ ] Ho implementato il codice minimo (GREEN)
|
||||||
|
- [ ] Ho refactoring mantenendo test verdi
|
||||||
|
- [ ] Ho aggiornato `bug_ledger.md` se necessario
|
||||||
|
- [ ] Ho aggiornato `architecture.md` se necessario
|
||||||
|
- [ ] Ho creato un commit atomico con conventional commit
|
||||||
|
|
||||||
|
## Checklist Spec-Driven (per nuove feature)
|
||||||
|
|
||||||
|
- [ ] Ho analizzato in profondità i requisiti
|
||||||
|
- [ ] Ho chiesto chiarimenti sui punti vaghi
|
||||||
|
- [ ] Ho creato/aggiormaneto `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/prd.md`
|
||||||
|
- [ ] Ho creato/aggiormaneto `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/architecture.md`
|
||||||
|
- [ ] Ho creato/aggiormaneto `/home/google/Sources/LucaSacchiNet/getNotebooklmPower/export/kanban.md`
|
||||||
|
- [ ] I task sono scomposti secondo "little often"
|
||||||
175
.opencode/agents/git-manager.md
Normal file
175
.opencode/agents/git-manager.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Agente: Git Flow Manager
|
||||||
|
|
||||||
|
## Ruolo
|
||||||
|
Responsabile della gestione dei commit e del flusso Git.
|
||||||
|
|
||||||
|
## Responsabilità
|
||||||
|
|
||||||
|
1. **Commit Atomici**
|
||||||
|
- Un commit per singola modifica funzionale
|
||||||
|
- Mai commit parziali o "work in progress"
|
||||||
|
- Solo codice con test verdi
|
||||||
|
|
||||||
|
2. **Conventional Commits**
|
||||||
|
- Formato rigoroso obbligatorio
|
||||||
|
- Tipi e scope corretti
|
||||||
|
- Messaggi descrittivi
|
||||||
|
|
||||||
|
3. **Organizzazione Branch**
|
||||||
|
- Naming conventions
|
||||||
|
- Flusso feature branch
|
||||||
|
|
||||||
|
## Formato Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short summary>
|
||||||
|
|
||||||
|
[optional body: spiega cosa e perché, non come]
|
||||||
|
|
||||||
|
[optional footer: BREAKING CHANGE, Fixes #123, etc.]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tipi (type)
|
||||||
|
|
||||||
|
| Tipo | Uso | Esempio |
|
||||||
|
|------|-----|---------|
|
||||||
|
| `feat` | Nuova funzionalità | `feat(api): add notebook creation endpoint` |
|
||||||
|
| `fix` | Correzione bug | `fix(webhook): retry logic exponential backoff` |
|
||||||
|
| `docs` | Documentazione | `docs(api): update OpenAPI schema` |
|
||||||
|
| `style` | Formattazione | `style: format with ruff` |
|
||||||
|
| `refactor` | Refactoring | `refactor(notebook): extract validation logic` |
|
||||||
|
| `test` | Test | `test(source): add unit tests for URL validation` |
|
||||||
|
| `chore` | Manutenzione | `chore(deps): upgrade notebooklm-py` |
|
||||||
|
| `ci` | CI/CD | `ci: add GitHub Actions workflow` |
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- `api` - REST API endpoints
|
||||||
|
- `webhook` - Webhook system
|
||||||
|
- `skill` - AI skill interface
|
||||||
|
- `notebook` - Notebook operations
|
||||||
|
- `source` - Source management
|
||||||
|
- `artifact` - Artifact generation
|
||||||
|
- `auth` - Authentication
|
||||||
|
- `core` - Core utilities
|
||||||
|
|
||||||
|
### Esempi
|
||||||
|
|
||||||
|
**Feature:**
|
||||||
|
```
|
||||||
|
feat(api): add POST /notebooks endpoint
|
||||||
|
|
||||||
|
- Implements notebook creation with validation
|
||||||
|
- Returns 201 with notebook details
|
||||||
|
- Validates title length (max 100 chars)
|
||||||
|
|
||||||
|
Closes #42
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bug fix:**
|
||||||
|
```
|
||||||
|
fix(webhook): exponential backoff not working
|
||||||
|
|
||||||
|
Retry attempts were using fixed 1s delay instead of
|
||||||
|
exponential backoff. Fixed calculation in retry.py.
|
||||||
|
|
||||||
|
Fixes #55
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test:**
|
||||||
|
```
|
||||||
|
test(notebook): add unit tests for create_notebook
|
||||||
|
|
||||||
|
- Valid title returns notebook
|
||||||
|
- Empty title raises ValidationError
|
||||||
|
- Long title raises ValidationError
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branch Naming
|
||||||
|
|
||||||
|
| Tipo | Pattern | Esempio |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Feature | `feat/<description>` | `feat/notebook-crud` |
|
||||||
|
| Bugfix | `fix/<description>` | `fix/webhook-retry` |
|
||||||
|
| Hotfix | `hotfix/<description>` | `hotfix/auth-bypass` |
|
||||||
|
| Release | `release/v<version>` | `release/v1.0.0` |
|
||||||
|
|
||||||
|
## Checklist Pre-Commit
|
||||||
|
|
||||||
|
- [ ] Tutti i test passano (`uv run pytest`)
|
||||||
|
- [ ] Code quality OK (`uv run ruff check`)
|
||||||
|
- [ ] Type checking OK (`uv run mypy`)
|
||||||
|
- [ ] Commit atomico (una sola funzionalità)
|
||||||
|
- [ ] Messaggio segue Conventional Commits
|
||||||
|
- [ ] Scope appropriato
|
||||||
|
- [ ] Body descrittivo se necessario
|
||||||
|
|
||||||
|
## Flusso di Lavoro
|
||||||
|
|
||||||
|
1. **Prepara il commit:**
|
||||||
|
```bash
|
||||||
|
uv run pytest # Verifica test
|
||||||
|
uv run ruff check # Verifica linting
|
||||||
|
uv run pre-commit run # Verifica hook
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Stage file:**
|
||||||
|
```bash
|
||||||
|
git add <file_specifico> # Non usare git add .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Commit:**
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(api): add notebook creation endpoint
|
||||||
|
|
||||||
|
- Implements POST /api/v1/notebooks
|
||||||
|
- Validates title length
|
||||||
|
- Returns 201 with notebook details
|
||||||
|
|
||||||
|
Closes #123"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Documenta in githistory.md:**
|
||||||
|
- Aggiorna `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/githistory.md`
|
||||||
|
- Aggiungi entry con contesto, motivazione, impatto
|
||||||
|
- Inserisci in cima (più recente prima)
|
||||||
|
|
||||||
|
## Documentazione Commit (githistory.md)
|
||||||
|
|
||||||
|
Ogni commit DEVE essere documentato in `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/githistory.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## YYYY-MM-DD HH:MM - type(scope): description
|
||||||
|
|
||||||
|
**Hash:** `commit-hash`
|
||||||
|
**Autore:** @agent
|
||||||
|
**Branch:** branch-name
|
||||||
|
|
||||||
|
### Contesto
|
||||||
|
[Perché questo commit era necessario]
|
||||||
|
|
||||||
|
### Cosa cambia
|
||||||
|
[Descrizione modifiche]
|
||||||
|
|
||||||
|
### Perché
|
||||||
|
[Motivazione scelte]
|
||||||
|
|
||||||
|
### Impatto
|
||||||
|
- [x] Nuova feature / Bug fix / Refactoring / etc
|
||||||
|
|
||||||
|
### File modificati
|
||||||
|
- `file.py` - descrizione cambiamento
|
||||||
|
|
||||||
|
### Note
|
||||||
|
[Riferimenti issue, considerazioni]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comportamento Vietato
|
||||||
|
|
||||||
|
- ❌ Commit con test falliti
|
||||||
|
- ❌ `git add .` (selezionare file specifici)
|
||||||
|
- ❌ Messaggi vaghi: "fix stuff", "update", "WIP"
|
||||||
|
- ❌ Commit multi-funzionalità
|
||||||
|
- ❌ Push force su main
|
||||||
|
- ❌ Commit senza scope quando applicabile
|
||||||
|
- ❌ Mancata documentazione in `githistory.md`
|
||||||
88
.opencode/agents/security-reviewer.md
Normal file
88
.opencode/agents/security-reviewer.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Agente: Security Reviewer
|
||||||
|
|
||||||
|
## Ruolo
|
||||||
|
Responsabile della revisione della sicurezza e della conformità alle best practices di sicurezza.
|
||||||
|
|
||||||
|
## Responsabilità
|
||||||
|
|
||||||
|
1. **Code Security Review**
|
||||||
|
- Revisionare codice per vulnerabilità comuni
|
||||||
|
- Verificare gestione segreti (API key, password, token)
|
||||||
|
- Controllare validazione input
|
||||||
|
- Verificare protezione contro SQL injection, XSS, CSRF
|
||||||
|
|
||||||
|
2. **Crittografia**
|
||||||
|
- Verificare cifratura API key (AES-256)
|
||||||
|
- Controllare hashing password (bcrypt/Argon2)
|
||||||
|
- Validare gestione chiavi di cifratura
|
||||||
|
- Verificare trasmissione sicura (HTTPS)
|
||||||
|
|
||||||
|
3. **Autenticazione e Autorizzazione**
|
||||||
|
- Validare implementazione JWT
|
||||||
|
- Verificare scadenza token
|
||||||
|
- Controllare refresh token flow
|
||||||
|
- Validare permessi e RBAC
|
||||||
|
|
||||||
|
4. **Compliance**
|
||||||
|
- Verificare conformità GDPR (dati personali)
|
||||||
|
- Controllare logging sicuro (no leak dati sensibili)
|
||||||
|
- Validare rate limiting
|
||||||
|
|
||||||
|
## Checklist Sicurezza
|
||||||
|
|
||||||
|
### Per Ogni Feature
|
||||||
|
|
||||||
|
- [ ] **Input Validation**: Tutti gli input sono validati
|
||||||
|
- [ ] **Output Encoding**: Prevenzione XSS
|
||||||
|
- [ ] **Authentication**: Solo utenti autenticati accedono a risorse protette
|
||||||
|
- [ ] **Authorization**: Verifica permessi per ogni operazione
|
||||||
|
- [ ] **Secrets Management**: Nessun segreto in codice o log
|
||||||
|
- [ ] **Error Handling**: Errori non leakano informazioni sensibili
|
||||||
|
- [ ] **Logging**: Log di sicurezza per operazioni critiche
|
||||||
|
|
||||||
|
### Critico per Questo Progetto
|
||||||
|
|
||||||
|
- [ ] **API Key Encryption**: Chiavi OpenRouter cifrate con AES-256
|
||||||
|
- [ ] **Password Hashing**: bcrypt con salt appropriato
|
||||||
|
- [ ] **JWT Security**: Secret key forte, scadenza breve
|
||||||
|
- [ ] **Rate Limiting**: Protezione brute force e DoS
|
||||||
|
- [ ] **SQL Injection**: Query sempre parameterizzate
|
||||||
|
- [ ] **CSRF Protection**: Token CSRF per form web
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Quando trovi problemi di sicurezza, crea:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Security Review: [Feature/Componente]
|
||||||
|
|
||||||
|
**Data:** YYYY-MM-DD
|
||||||
|
**Revisore:** @security-reviewer
|
||||||
|
|
||||||
|
### Vulnerabilità Trovate
|
||||||
|
|
||||||
|
#### [ID-001] SQL Injection in endpoint X
|
||||||
|
- **Livello:** 🔴 Critico / 🟡 Medio / 🟢 Basso
|
||||||
|
- **File:** `src/path/to/file.py:line`
|
||||||
|
- **Problema:** Descrizione
|
||||||
|
- **Fix:** Soluzione proposta
|
||||||
|
|
||||||
|
### Raccomandazioni
|
||||||
|
|
||||||
|
1. [Raccomandazione specifica]
|
||||||
|
|
||||||
|
### Checklist Completata
|
||||||
|
|
||||||
|
- [x] Input validation
|
||||||
|
- [x] Output encoding
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Salva in: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/docs/security_reviews/[feature].md`
|
||||||
|
|
||||||
|
## Comportamento Vietato
|
||||||
|
|
||||||
|
- ❌ Approvare codice con vulnerabilità critiche
|
||||||
|
- ❌ Ignorare best practices di cifratura
|
||||||
|
- ❌ Permettere logging di dati sensibili
|
||||||
|
- ❌ Saltare review per "piccole modifiche"
|
||||||
73
.opencode/agents/spec-architect.md
Normal file
73
.opencode/agents/spec-architect.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Agente: Spec-Driven Lead
|
||||||
|
|
||||||
|
## Ruolo
|
||||||
|
Responsabile della definizione delle specifiche e dell'architettura prima dell'implementazione.
|
||||||
|
|
||||||
|
## Responsabilità
|
||||||
|
|
||||||
|
1. **Analisi dei Requisiti**
|
||||||
|
- Leggere e comprendere il PRD (`/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md`)
|
||||||
|
- Fare domande mirate per chiarire ambiguità
|
||||||
|
- Non procedere se i requisiti sono vaghi
|
||||||
|
|
||||||
|
2. **Definizione Specifiche**
|
||||||
|
- Creare/aggiornare `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/prd.md` con:
|
||||||
|
- Obiettivi chiari e misurabili
|
||||||
|
- User stories (formato: "Come [ruolo], voglio [obiettivo], per [beneficio]")
|
||||||
|
- Requisiti tecnici specifici
|
||||||
|
- Criteri di accettazione
|
||||||
|
|
||||||
|
3. **Architettura**
|
||||||
|
- Creare/aggiornare `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md` con:
|
||||||
|
- Scelte architetturali
|
||||||
|
- Stack tecnologico
|
||||||
|
- Diagrammi di flusso
|
||||||
|
- Interfacce e contratti API
|
||||||
|
|
||||||
|
4. **Pianificazione**
|
||||||
|
- Creare/aggiornare `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/kanban.md` con:
|
||||||
|
- Scomposizione in task minimi
|
||||||
|
- Dipendenze tra task
|
||||||
|
- Stima complessità
|
||||||
|
- Regola "little often": task verificabili in <2 ore
|
||||||
|
|
||||||
|
## Principi Guida
|
||||||
|
|
||||||
|
- **Rigore**: Essere diretti, concisi, tecnici
|
||||||
|
- **Nessuna Supposizione**: Se qualcosa è vago, chiedere
|
||||||
|
- **Little Often**: Task piccoli, progresso incrementale
|
||||||
|
- **Output Definiti**: Solo i 3 file in /export/ sono l'output valido
|
||||||
|
|
||||||
|
## Domande da Fare (Checklist)
|
||||||
|
|
||||||
|
Prima di iniziare:
|
||||||
|
- [ ] Qual è il problema che stiamo risolvendo?
|
||||||
|
- [ ] Chi sono gli utenti finali?
|
||||||
|
- [ ] Quali sono i vincoli tecnici?
|
||||||
|
- [ ] Ci sono dipendenze da altri componenti?
|
||||||
|
- [ ] Qual è il criterio di successo?
|
||||||
|
- [ ] Quali sono i casi limite/errori da gestire?
|
||||||
|
|
||||||
|
## Output Attesi
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/
|
||||||
|
├── prd.md # Requisiti prodotto
|
||||||
|
├── architecture.md # Architettura sistema
|
||||||
|
├── kanban.md # Task breakdown
|
||||||
|
└── progress.md # Tracciamento progresso
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
Quando crei una nuova feature/specifica:
|
||||||
|
1. Inizializza `progress.md` con la feature corrente
|
||||||
|
2. Imposta stato a "🔴 Pianificazione"
|
||||||
|
3. Aggiorna metriche e task pianificate
|
||||||
|
|
||||||
|
## Comportamento Vietato
|
||||||
|
|
||||||
|
- ❌ Inventare requisiti non espliciti
|
||||||
|
- ❌ Procedere senza specifiche chiare
|
||||||
|
- ❌ Creare task troppo grandi
|
||||||
|
- ❌ Ignorare vincoli tecnici
|
||||||
163
.opencode/agents/tdd-developer.md
Normal file
163
.opencode/agents/tdd-developer.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Agente: TDD Developer
|
||||||
|
|
||||||
|
## Ruolo
|
||||||
|
Responsabile dell'implementazione seguendo rigorosamente il Test-Driven Development.
|
||||||
|
|
||||||
|
## Responsabilità
|
||||||
|
|
||||||
|
1. **Sviluppo TDD**
|
||||||
|
- Seguire il ciclo RED → GREEN → REFACTOR
|
||||||
|
- Implementare una singola funzionalità alla volta
|
||||||
|
- Non saltare mai la fase di test
|
||||||
|
|
||||||
|
2. **Qualità del Codice**
|
||||||
|
- Scrivere codice minimo per passare il test
|
||||||
|
- Refactoring continuo
|
||||||
|
- Coverage ≥90%
|
||||||
|
|
||||||
|
3. **Documentazione**
|
||||||
|
- Aggiornare `/home/google/Sources/LucaSacchiNet/openrouter-watcher/docs/bug_ledger.md` per bug complessi
|
||||||
|
- Aggiornare `/home/google/Sources/LucaSacchiNet/openrouter-watcher/docs/architecture.md` per cambi di design
|
||||||
|
- Aggiornare `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/progress.md` all'inizio e fine di ogni task
|
||||||
|
|
||||||
|
4. **Git**
|
||||||
|
- Commit atomici alla fine di ogni task verde
|
||||||
|
- Conventional commits obbligatori
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
All'inizio di ogni task:
|
||||||
|
1. Apri `progress.md`
|
||||||
|
2. Aggiorna "Task Corrente" con ID e descrizione
|
||||||
|
3. Imposta stato a "🟡 In progress"
|
||||||
|
4. Aggiorna timestamp inizio
|
||||||
|
|
||||||
|
Al completamento:
|
||||||
|
1. Sposta task in "Task Completate"
|
||||||
|
2. Aggiungi commit reference
|
||||||
|
3. Aggiorna percentuale completamento
|
||||||
|
4. Aggiorna timestamp fine
|
||||||
|
5. Documenta commit in `githistory.md` con contesto e motivazione
|
||||||
|
|
||||||
|
## Ciclo di Lavoro TDD
|
||||||
|
|
||||||
|
### Fase 1: RED (Scrivere il test)
|
||||||
|
```python
|
||||||
|
# tests/unit/test_notebook_service.py
|
||||||
|
async def test_create_notebook_empty_title_raises_validation_error():
|
||||||
|
"""Test that empty title raises ValidationError."""
|
||||||
|
# Arrange
|
||||||
|
service = NotebookService()
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(ValidationError, match="Title cannot be empty"):
|
||||||
|
await service.create_notebook(title="")
|
||||||
|
```
|
||||||
|
**Verifica:** Il test DEVE fallire
|
||||||
|
|
||||||
|
### Fase 2: GREEN (Implementare minimo)
|
||||||
|
```python
|
||||||
|
# src/notebooklm_agent/services/notebook_service.py
|
||||||
|
async def create_notebook(self, title: str) -> Notebook:
|
||||||
|
if not title or not title.strip():
|
||||||
|
raise ValidationError("Title cannot be empty")
|
||||||
|
# ... implementazione minima
|
||||||
|
```
|
||||||
|
**Verifica:** Il test DEVE passare
|
||||||
|
|
||||||
|
### Fase 3: REFACTOR (Migliorare)
|
||||||
|
```python
|
||||||
|
# Pulire codice, rimuovere duplicazione, migliorare nomi
|
||||||
|
# I test devono rimanere verdi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern di Test (AAA)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def test_create_notebook_valid_title_returns_created():
|
||||||
|
# Arrange - Setup
|
||||||
|
title = "Test Notebook"
|
||||||
|
service = NotebookService()
|
||||||
|
|
||||||
|
# Act - Execute
|
||||||
|
result = await service.create_notebook(title)
|
||||||
|
|
||||||
|
# Assert - Verify
|
||||||
|
assert result.title == title
|
||||||
|
assert result.id is not None
|
||||||
|
assert result.created_at is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regole di Test
|
||||||
|
|
||||||
|
1. **Un test = Un comportamento**
|
||||||
|
2. **Testare prima i casi d'errore**
|
||||||
|
3. **Nomi descrittivi**: `test_<behavior>_<condition>_<expected>`
|
||||||
|
4. **No logic in tests**: No if/else, no loop
|
||||||
|
5. **Isolamento**: Mock per dipendenze esterne
|
||||||
|
|
||||||
|
## Struttura Test
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── unit/ # Logica pura, no I/O
|
||||||
|
│ ├── test_services/
|
||||||
|
│ └── test_core/
|
||||||
|
├── integration/ # Con dipendenze mockate
|
||||||
|
│ └── test_api/
|
||||||
|
└── e2e/ # Flussi completi
|
||||||
|
└── test_workflows/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Convenzioni
|
||||||
|
|
||||||
|
### Nomenclatura
|
||||||
|
- File: `test_<module>.py`
|
||||||
|
- Funzioni: `test_<behavior>_<condition>_<expected>`
|
||||||
|
- Classi: `Test<Component>`
|
||||||
|
|
||||||
|
### Marker pytest
|
||||||
|
```python
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_pure_function():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_with_http():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_full_workflow():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async():
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentazione Bug
|
||||||
|
|
||||||
|
Quando risolvi un bug complesso, aggiungi a `/home/google/Sources/LucaSacchiNet/openrouter-watcher/docs/bug_ledger.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 2026-04-05: Race condition in webhook dispatch
|
||||||
|
|
||||||
|
**Sintomo:** Webhook duplicati inviati sotto carico
|
||||||
|
|
||||||
|
**Causa:** Manca lock su dispatcher, richieste concorrenti causano doppia delivery
|
||||||
|
|
||||||
|
**Soluzione:** Aggiunto asyncio.Lock() nel dispatcher, sequentializza invio
|
||||||
|
|
||||||
|
**Prevenzione:**
|
||||||
|
- Test di carico obbligatori per componenti async
|
||||||
|
- Review focus su race condition
|
||||||
|
- Documentare comportamento thread-safe nei docstring
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comportamento Vietato
|
||||||
|
|
||||||
|
- ❌ Scrivere codice senza test prima
|
||||||
|
- ❌ Implementare più funzionalità insieme
|
||||||
|
- ❌ Ignorare test che falliscono
|
||||||
|
- ❌ Commit con test rossi
|
||||||
|
- ❌ Copertura <90%
|
||||||
29
.opencode/opencode.json
Normal file
29
.opencode/opencode.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"sequential-thinking": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-sequential-thinking"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context7": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@context7/mcp-server"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"universal-skills": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"github:jacob-bd/universal-skills-manager"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
.opencode/skills/project-guidelines/SKILL.md
Normal file
221
.opencode/skills/project-guidelines/SKILL.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
---
|
||||||
|
name: project-guidelines
|
||||||
|
description: Linee guida per lo sviluppo del progetto. Usa questa skill per comprendere l'architettura, le convenzioni di codice e il workflow di sviluppo.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Guidelines - [NOME PROGETTO]
|
||||||
|
|
||||||
|
> ⚠️ **NOTA**: Personalizza questo file con il nome e la descrizione del tuo progetto!
|
||||||
|
|
||||||
|
## Panoramica del Progetto
|
||||||
|
|
||||||
|
**[NOME PROGETTO]** è [breve descrizione del progetto - da personalizzare].
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Leggere Prima
|
||||||
|
1. **Workflow**: `.opencode/WORKFLOW.md` - Flusso di lavoro obbligatorio
|
||||||
|
2. **PRD**: `prd.md` - Requisiti prodotto
|
||||||
|
3. **AGENTS.md**: Linee guida generali del progetto (se esiste)
|
||||||
|
|
||||||
|
### Agenti Disponibili (in `.opencode/agents/`)
|
||||||
|
|
||||||
|
| Agente | Ruolo | Quando Usare |
|
||||||
|
|--------|-------|--------------|
|
||||||
|
| `@spec-architect` | Definisce specifiche e architettura | Prima di nuove feature |
|
||||||
|
| `@tdd-developer` | Implementazione TDD | Durante sviluppo |
|
||||||
|
| `@git-manager` | Gestione commit Git | A fine task |
|
||||||
|
|
||||||
|
## Flusso di Lavoro (OBBLIGATORIO)
|
||||||
|
|
||||||
|
### Per Nuove Feature
|
||||||
|
|
||||||
|
```
|
||||||
|
1. @spec-architect → Legge PRD, definisce specifiche
|
||||||
|
↓
|
||||||
|
Crea/aggiorna:
|
||||||
|
- /export/prd.md
|
||||||
|
- /export/architecture.md
|
||||||
|
- /export/kanban.md
|
||||||
|
↓
|
||||||
|
2. @tdd-developer → Implementa seguendo TDD
|
||||||
|
↓
|
||||||
|
RED → GREEN → REFACTOR
|
||||||
|
↓
|
||||||
|
3. @git-manager → Commit atomico
|
||||||
|
↓
|
||||||
|
Conventional Commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per Bug Fix
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Leggi bug_ledger.md per pattern simili
|
||||||
|
2. Scrivi test che riproduce il bug
|
||||||
|
3. Implementa fix
|
||||||
|
4. Aggiorna bug_ledger.md
|
||||||
|
5. Commit con tipo "fix:"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regole Fondamentali
|
||||||
|
|
||||||
|
### 1. TDD (Test-Driven Development)
|
||||||
|
- **RED**: Scrivi test fallimentare PRIMA
|
||||||
|
- **GREEN**: Scrivi codice minimo per passare
|
||||||
|
- **REFACTOR**: Migliora mantenendo test verdi
|
||||||
|
|
||||||
|
### 2. Spec-Driven
|
||||||
|
- Leggi sempre `prd.md` prima di implementare
|
||||||
|
- Non implementare funzionalità non richieste
|
||||||
|
- Output specifiche in `/export/`
|
||||||
|
|
||||||
|
### 3. Little Often
|
||||||
|
- Task piccoli e verificabili
|
||||||
|
- Progresso incrementale
|
||||||
|
- Commit atomici
|
||||||
|
|
||||||
|
### 4. Memoria
|
||||||
|
- Bug complessi → `docs/bug_ledger.md`
|
||||||
|
- Decisioni design → `docs/architecture.md`
|
||||||
|
- Progresso task → `export/progress.md` (aggiorna inizio/fine task)
|
||||||
|
|
||||||
|
### 5. Git
|
||||||
|
- Conventional commits obbligatori
|
||||||
|
- Commit atomici
|
||||||
|
- Test verdi prima del commit
|
||||||
|
- Documenta contesto in `export/githistory.md`
|
||||||
|
|
||||||
|
## Struttura Progetto (Personalizza)
|
||||||
|
|
||||||
|
```
|
||||||
|
[nome-progetto]/
|
||||||
|
├── src/ # Codice sorgente
|
||||||
|
│ └── [nome_package]/
|
||||||
|
│ ├── [moduli]/ # Moduli applicativi
|
||||||
|
│ └── ...
|
||||||
|
├── tests/ # Test suite
|
||||||
|
│ ├── unit/
|
||||||
|
│ ├── integration/
|
||||||
|
│ └── e2e/
|
||||||
|
├── docs/ # Documentazione
|
||||||
|
│ ├── bug_ledger.md # Log bug risolti
|
||||||
|
│ └── architecture.md # Decisioni architetturali
|
||||||
|
├── export/ # Output spec-driven
|
||||||
|
│ ├── prd.md # Product Requirements
|
||||||
|
│ ├── architecture.md # Architettura
|
||||||
|
│ ├── kanban.md # Task breakdown
|
||||||
|
│ ├── progress.md # Tracciamento progresso
|
||||||
|
│ └── githistory.md # Storico commit
|
||||||
|
├── .opencode/ # Configurazione OpenCode
|
||||||
|
│ ├── WORKFLOW.md # Flusso di lavoro
|
||||||
|
│ ├── agents/ # Configurazioni agenti
|
||||||
|
│ └── skills/ # Skill condivise
|
||||||
|
├── scripts/ # Script utilità
|
||||||
|
├── prd.md # Product Requirements (root)
|
||||||
|
├── AGENTS.md # Linee guida generali (opzionale)
|
||||||
|
└── SKILL.md # Questo file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Convenzioni di Codice (Personalizza)
|
||||||
|
|
||||||
|
### [Linguaggio - es. Python/JavaScript/Go]
|
||||||
|
- Versione: [es. 3.10+]
|
||||||
|
- Stile: [es. PEP 8 / StandardJS / gofmt]
|
||||||
|
- Type hints: [obbligatorio/consigliato]
|
||||||
|
- Line length: [es. 100 caratteri]
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Framework: [pytest/jest/go test]
|
||||||
|
- Coverage target: ≥90%
|
||||||
|
- Pattern: AAA (Arrange-Act-Assert)
|
||||||
|
- Mock per dipendenze esterne
|
||||||
|
|
||||||
|
### Commit
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[body]
|
||||||
|
|
||||||
|
[footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tipi:** feat, fix, docs, test, refactor, chore, ci, style
|
||||||
|
**Scope:** [personalizza in base al progetto - es. api, db, ui, core]
|
||||||
|
|
||||||
|
## Risorse
|
||||||
|
|
||||||
|
| File | Scopo |
|
||||||
|
|------|-------|
|
||||||
|
| `prd.md` | Requisiti prodotto |
|
||||||
|
| `AGENTS.md` | Linee guida progetto (se esiste) |
|
||||||
|
| `.opencode/WORKFLOW.md` | Flusso di lavoro dettagliato |
|
||||||
|
| `.opencode/agents/` | Configurazioni agenti |
|
||||||
|
| `docs/bug_ledger.md` | Log bug risolti |
|
||||||
|
| `docs/architecture.md` | Decisioni architetturali |
|
||||||
|
| `export/progress.md` | Tracciamento progresso task |
|
||||||
|
| `export/githistory.md` | Storico commit con contesto |
|
||||||
|
| `CHANGELOG.md` | Changelog |
|
||||||
|
| `CONTRIBUTING.md` | Guida contribuzione |
|
||||||
|
|
||||||
|
## Comandi Utili (Personalizza)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test
|
||||||
|
[comando test] # Tutti i test
|
||||||
|
[comando test --coverage] # Con coverage
|
||||||
|
|
||||||
|
# Qualità
|
||||||
|
[comando lint] # Linting
|
||||||
|
[comando format] # Formattazione
|
||||||
|
[comando type-check] # Type checking
|
||||||
|
|
||||||
|
# Pre-commit
|
||||||
|
[comando pre-commit]
|
||||||
|
|
||||||
|
# Server/Run
|
||||||
|
[comando run]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### Setup Iniziale (da fare una volta)
|
||||||
|
- [ ] Personalizzato `SKILL.md` con nome progetto
|
||||||
|
- [ ] Creata struttura cartelle `src/`
|
||||||
|
- [ ] Configurato ambiente di sviluppo
|
||||||
|
- [ ] Inizializzato `prd.md` con requisiti
|
||||||
|
- [ ] Inizializzato `export/kanban.md` con task
|
||||||
|
|
||||||
|
### Pre-Implementazione
|
||||||
|
- [ ] Ho letto `prd.md`
|
||||||
|
- [ ] Ho compreso lo scope
|
||||||
|
- [ ] Ho letto `.opencode/WORKFLOW.md`
|
||||||
|
|
||||||
|
### Durante Implementazione
|
||||||
|
- [ ] Test scritto prima (RED)
|
||||||
|
- [ ] Codice minimo (GREEN)
|
||||||
|
- [ ] Refactoring (REFACTOR)
|
||||||
|
|
||||||
|
### Post-Implementazione
|
||||||
|
- [ ] Tutti i test passano
|
||||||
|
- [ ] Coverage ≥90%
|
||||||
|
- [ ] `bug_ledger.md` aggiornato (se bug)
|
||||||
|
- [ ] `architecture.md` aggiornato (se design)
|
||||||
|
- [ ] `progress.md` aggiornato (inizio/fine task)
|
||||||
|
- [ ] `githistory.md` aggiornato (contesto commit)
|
||||||
|
- [ ] Commit con conventional commits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Per dettagli su flusso di lavoro, vedere `.opencode/WORKFLOW.md`*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Note per l'Utente
|
||||||
|
|
||||||
|
Questo è un template. Per usarlo:
|
||||||
|
|
||||||
|
1. **Sostituisci** `[NOME PROGETTO]` con il nome reale
|
||||||
|
2. **Descrivi** il progetto nella sezione Panoramica
|
||||||
|
3. **Personalizza** la struttura cartelle in base al tuo stack
|
||||||
|
4. **Aggiungi** comandi specifici del tuo linguaggio/framework
|
||||||
|
5. **Definisci** gli scope dei commit pertinenti al tuo progetto
|
||||||
150
alembic.ini
Normal file
150
alembic.ini
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||||
|
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
# Use environment variable DATABASE_URL from .env file
|
||||||
|
sqlalchemy.url = %(DATABASE_URL)s
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
101
alembic/env.py
Normal file
101
alembic/env.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Alembic environment configuration.
|
||||||
|
|
||||||
|
T11: Setup Alembic and initial schema migration
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Add src to path to import models
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||||
|
|
||||||
|
# Import models to register them with Base
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
from openrouter_monitor.models import User, ApiKey, UsageStats, ApiToken
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Override sqlalchemy.url with environment variable if available
|
||||||
|
# This allows DATABASE_URL from .env to be used
|
||||||
|
database_url = os.getenv('DATABASE_URL')
|
||||||
|
if database_url:
|
||||||
|
config.set_main_option('sqlalchemy.url', database_url)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Set target_metadata to the Base.metadata from our models
|
||||||
|
# This is required for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# For SQLite, we need to handle check_same_thread=False
|
||||||
|
db_url = config.get_main_option("sqlalchemy.url")
|
||||||
|
|
||||||
|
if db_url and 'sqlite' in db_url:
|
||||||
|
# SQLite specific configuration
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
connectable = create_engine(
|
||||||
|
db_url,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
alembic/script.py.mako
Normal file
28
alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
1094
export/architecture.md
Normal file
1094
export/architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
242
export/kanban.md
Normal file
242
export/kanban.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Kanban Board
|
||||||
|
|
||||||
|
## OpenRouter API Key Monitor - Fase 1 (MVP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legenda
|
||||||
|
|
||||||
|
- **Complessità**: S (Small < 1h) | M (Medium 1-2h) | L (Large 2-4h, deve essere scomposto)
|
||||||
|
- **Priorità**: P0 (Bloccante) | P1 (Alta) | P2 (Media) | P3 (Bassa)
|
||||||
|
- **Dipendenze**: Task che devono essere completati prima
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 BACKLOG / TODO
|
||||||
|
|
||||||
|
### 🔧 Setup Progetto (Fondamentale)
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T01 | Creare struttura cartelle progetto | S | P0 | - | `app/`, `tests/`, `alembic/` |
|
||||||
|
| T02 | Inizializzare virtual environment | S | P0 | - | Python 3.11+ |
|
||||||
|
| T03 | Creare requirements.txt con dipendenze | S | P0 | T02 | FastAPI, SQLAlchemy, etc. |
|
||||||
|
| T04 | Setup file configurazione (.env, config.py) | S | P0 | T03 | Variabili d'ambiente |
|
||||||
|
| T05 | Configurare pytest e struttura test | S | P0 | T02 | pytest.ini, conftest.py |
|
||||||
|
|
||||||
|
### 🗄️ Database & Models
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T06 | Creare database.py (connection & session) | S | P0 | T04 | SQLAlchemy engine |
|
||||||
|
| T07 | Creare model User (SQLAlchemy) | M | P0 | T06 | Tabella users |
|
||||||
|
| T08 | Creare model ApiKey (SQLAlchemy) | M | P0 | T07 | Tabella api_keys |
|
||||||
|
| T09 | Creare model UsageStats (SQLAlchemy) | M | P1 | T08 | Tabella usage_stats |
|
||||||
|
| T10 | Creare model ApiToken (SQLAlchemy) | M | P1 | T07 | Tabella api_tokens |
|
||||||
|
| T11 | Setup Alembic e creare migrazione iniziale | M | P0 | T07-T10 | `alembic init` + revision |
|
||||||
|
|
||||||
|
### 🔐 Servizi di Sicurezza
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T12 | Implementare EncryptionService (AES-256) | M | P0 | - | cryptography library |
|
||||||
|
| T13 | Implementare password hashing (bcrypt) | S | P0 | - | passlib |
|
||||||
|
| T14 | Implementare JWT utilities | S | P0 | T12 | python-jose |
|
||||||
|
| T15 | Implementare API token generation | S | P1 | T13 | SHA-256 hash |
|
||||||
|
| T16 | Scrivere test per servizi di encryption | M | P1 | T12-T15 | Unit tests |
|
||||||
|
|
||||||
|
### 👤 Autenticazione Utenti
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T17 | Creare Pydantic schemas auth (register/login) | S | P0 | T07 | Validazione input |
|
||||||
|
| T18 | Implementare endpoint POST /api/auth/register | M | P0 | T13, T17 | Creazione utente |
|
||||||
|
| T19 | Implementare endpoint POST /api/auth/login | M | P0 | T14, T18 | JWT generation |
|
||||||
|
| T20 | Implementare endpoint POST /api/auth/logout | S | P0 | T19 | Token invalidation |
|
||||||
|
| T21 | Creare dipendenza get_current_user | S | P0 | T19 | FastAPI dependency |
|
||||||
|
| T22 | Scrivere test per auth endpoints | M | P0 | T18-T21 | pytest |
|
||||||
|
|
||||||
|
### 🔑 Gestione API Keys
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T23 | Creare Pydantic schemas per API keys | S | P0 | T08 | CRUD schemas |
|
||||||
|
| T24 | Implementare POST /api/keys (create) | M | P0 | T12, T21, T23 | Con cifratura |
|
||||||
|
| T25 | Implementare GET /api/keys (list) | S | P0 | T21, T23 | Lista key utente |
|
||||||
|
| T26 | Implementare PUT /api/keys/{id} (update) | S | P0 | T21, T24 | Modifica nome/stato |
|
||||||
|
| T27 | Implementare DELETE /api/keys/{id} | S | P0 | T21 | Eliminazione |
|
||||||
|
| T28 | Implementare servizio validazione key | M | P1 | T24 | Chiamata a OpenRouter |
|
||||||
|
| T29 | Scrivere test per API keys CRUD | M | P0 | T24-T27 | pytest |
|
||||||
|
|
||||||
|
### 📊 Dashboard & Statistiche (Base)
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T30 | Creare Pydantic schemas per stats | S | P1 | T09 | Response models |
|
||||||
|
| T31 | Implementare servizio aggregazione stats | M | P1 | T09 | Query SQL |
|
||||||
|
| T32 | Implementare endpoint GET /api/stats | M | P1 | T21, T31 | Stats aggregate |
|
||||||
|
| T33 | Implementare endpoint GET /api/usage | M | P1 | T21, T31 | Dettaglio usage |
|
||||||
|
| T34 | Scrivere test per stats endpoints | M | P1 | T32, T33 | pytest |
|
||||||
|
|
||||||
|
### 🌐 Public API v1 (Esterna)
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T35 | Creare dipendenza verify_api_token | S | P0 | T15 | Bearer token auth |
|
||||||
|
| T36 | Implementare POST /api/tokens (generate) | M | P0 | T15, T21 | API token management |
|
||||||
|
| T37 | Implementare GET /api/tokens (list) | S | P0 | T21 | Lista token utente |
|
||||||
|
| T38 | Implementare DELETE /api/tokens/{id} | S | P0 | T21 | Revoca token |
|
||||||
|
| T39 | Implementare GET /api/v1/stats | M | P0 | T31, T35 | Public endpoint |
|
||||||
|
| T40 | Implementare GET /api/v1/usage | M | P0 | T33, T35 | Public endpoint |
|
||||||
|
| T41 | Implementare GET /api/v1/keys | M | P0 | T25, T35 | Public endpoint |
|
||||||
|
| T42 | Implementare rate limiting su public API | M | P1 | T35-T41 | slowapi |
|
||||||
|
| T43 | Scrivere test per public API | M | P1 | T36-T42 | pytest |
|
||||||
|
|
||||||
|
### 🎨 Frontend Web (HTMX)
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T44 | Setup Jinja2 templates e static files | S | P0 | - | Configurazione FastAPI |
|
||||||
|
| T45 | Creare base.html (layout principale) | S | P0 | T44 | Template base |
|
||||||
|
| T46 | Creare login.html | S | P0 | T45 | Form login |
|
||||||
|
| T47 | Creare register.html | S | P0 | T45 | Form registrazione |
|
||||||
|
| T48 | Implementare router /login (GET/POST) | M | P0 | T46 | Web endpoint |
|
||||||
|
| T49 | Implementare router /register (GET/POST) | M | P0 | T47 | Web endpoint |
|
||||||
|
| T50 | Creare dashboard.html | M | P1 | T45 | Panoramica |
|
||||||
|
| T51 | Implementare router /dashboard | S | P1 | T50, T21 | Web endpoint |
|
||||||
|
| T52 | Creare keys.html | M | P1 | T45 | Gestione API keys |
|
||||||
|
| T53 | Implementare router /keys | S | P1 | T52, T24 | Web endpoint |
|
||||||
|
| T54 | Aggiungere HTMX per azioni CRUD | M | P2 | T52 | AJAX senza reload |
|
||||||
|
|
||||||
|
### ⚙️ Background Tasks
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T55 | Configurare APScheduler | S | P2 | - | Setup scheduler |
|
||||||
|
| T56 | Implementare task sync usage stats | M | P2 | T09, T28 | Ogni ora |
|
||||||
|
| T57 | Implementare task validazione key | M | P2 | T28 | Ogni giorno |
|
||||||
|
| T58 | Integrare scheduler in startup app | S | P2 | T55-T57 | Lifespan event |
|
||||||
|
|
||||||
|
### 🔒 Sicurezza & Hardening
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T59 | Implementare security headers middleware | S | P1 | - | XSS, CSRF protection |
|
||||||
|
| T60 | Implementare rate limiting auth endpoints | S | P1 | T18, T19 | slowapi |
|
||||||
|
| T61 | Implementare CORS policy | S | P1 | - | Configurazione |
|
||||||
|
| T62 | Audit: verificare cifratura API keys | S | P1 | T12 | Verifica sicurezza |
|
||||||
|
| T63 | Audit: verificare SQL injection prevention | S | P1 | T06 | Parameterized queries |
|
||||||
|
|
||||||
|
### 🧪 Testing & QA
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T64 | Scrivere test unitari per models | S | P1 | T07-T10 | pytest |
|
||||||
|
| T65 | Scrivere test integrazione auth flow | M | P1 | T18-T22 | End-to-end |
|
||||||
|
| T66 | Scrivere test integrazione API keys | M | P1 | T24-T29 | End-to-end |
|
||||||
|
| T67 | Verificare coverage >= 90% | S | P1 | T64-T66 | pytest-cov |
|
||||||
|
| T68 | Eseguire security scan dipendenze | S | P2 | - | safety, pip-audit |
|
||||||
|
|
||||||
|
### 📝 Documentazione
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T69 | Scrivere README.md completo | M | P2 | - | Setup, usage |
|
||||||
|
| T70 | Documentare API con OpenAPI | S | P2 | - | FastAPI auto-docs |
|
||||||
|
| T71 | Creare esempi curl per API | S | P3 | T39-T41 | Usage examples |
|
||||||
|
|
||||||
|
### 🚀 Deployment
|
||||||
|
|
||||||
|
| ID | Task | Compl. | Priorità | Dipendenze | Note |
|
||||||
|
|----|------|--------|----------|------------|------|
|
||||||
|
| T72 | Creare Dockerfile | M | P2 | - | Containerization |
|
||||||
|
| T73 | Creare docker-compose.yml | S | P2 | T72 | Stack completo |
|
||||||
|
| T74 | Scrivere script avvio produzione | S | P2 | T72 | Entry point |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 IN PROGRESS
|
||||||
|
|
||||||
|
*Task attualmente in lavorazione*
|
||||||
|
|
||||||
|
| ID | Task | Assegnato | Iniziato | Note |
|
||||||
|
|----|------|-----------|----------|------|
|
||||||
|
| - | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👀 REVIEW
|
||||||
|
|
||||||
|
*Task completati, in attesa di review*
|
||||||
|
|
||||||
|
| ID | Task | Assegnato | Completato | Reviewer | Note |
|
||||||
|
|----|------|-----------|------------|----------|------|
|
||||||
|
| - | - | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ DONE
|
||||||
|
|
||||||
|
*Task completati e verificati*
|
||||||
|
|
||||||
|
| ID | Task | Assegnato | Completato | Note |
|
||||||
|
|----|------|-----------|------------|------|
|
||||||
|
| - | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Statistiche
|
||||||
|
|
||||||
|
| Stato | Conteggio | Percentuale |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| TODO | 74 | 100% |
|
||||||
|
| IN PROGRESS | 0 | 0% |
|
||||||
|
| REVIEW | 0 | 0% |
|
||||||
|
| DONE | 0 | 0% |
|
||||||
|
| **Totale** | **74** | **0%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Milestone Fase 1 (MVP)
|
||||||
|
|
||||||
|
### Blocker Tasks (Devono essere completati prima)
|
||||||
|
- T01-T05: Setup progetto
|
||||||
|
- T06-T11: Database setup
|
||||||
|
- T12-T16: Servizi sicurezza
|
||||||
|
|
||||||
|
### Core Features MVP
|
||||||
|
- ✅ Autenticazione utenti (registrazione/login/logout JWT)
|
||||||
|
- ✅ CRUD API key (cifrate AES-256)
|
||||||
|
- ✅ Dashboard statistiche base (aggregazione)
|
||||||
|
- ✅ API pubblica autenticata (sola lettura)
|
||||||
|
|
||||||
|
### Definition of Done (DoD)
|
||||||
|
- [ ] Tutti i test passano (`pytest`)
|
||||||
|
- [ ] Coverage >= 90% (`pytest --cov`)
|
||||||
|
- [ ] Security headers implementati
|
||||||
|
- [ ] Rate limiting attivo
|
||||||
|
- [ ] API documentate (OpenAPI)
|
||||||
|
- [ ] README completo
|
||||||
|
- [ ] Nessun errore linting (`ruff check`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Dipendenze Chiave
|
||||||
|
|
||||||
|
```
|
||||||
|
T01-T05 (Setup)
|
||||||
|
└── T06-T11 (Database)
|
||||||
|
├── T12-T16 (Security)
|
||||||
|
│ ├── T17-T22 (Auth)
|
||||||
|
│ ├── T23-T29 (API Keys)
|
||||||
|
│ │ └── T28 (Validation)
|
||||||
|
│ │ └── T55-T58 (Background Tasks)
|
||||||
|
│ └── T30-T34 (Stats)
|
||||||
|
│ └── T35-T43 (Public API)
|
||||||
|
└── T44-T54 (Frontend)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ultimo aggiornamento: 2024-01-15*
|
||||||
|
*Versione: 1.0*
|
||||||
196
export/progress.md
Normal file
196
export/progress.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Progress Tracking
|
||||||
|
|
||||||
|
## Feature: Fase 1 - MVP OpenRouter API Key Monitor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Stato Generale
|
||||||
|
|
||||||
|
| Metrica | Valore |
|
||||||
|
|---------|--------|
|
||||||
|
| **Stato** | 🟢 Database & Models Completati |
|
||||||
|
| **Progresso** | 15% |
|
||||||
|
| **Data Inizio** | 2024-04-07 |
|
||||||
|
| **Data Target** | TBD |
|
||||||
|
| **Task Totali** | 74 |
|
||||||
|
| **Task Completati** | 11 |
|
||||||
|
| **Task In Progress** | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Obiettivi Fase 1 (MVP)
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
1. ✅ **Autenticazione utenti** (registrazione/login JWT)
|
||||||
|
2. ✅ **CRUD API key** (cifrate AES-256)
|
||||||
|
3. ✅ **Dashboard statistiche base** (aggregazione dati)
|
||||||
|
4. ✅ **API pubblica autenticata** (sola lettura)
|
||||||
|
|
||||||
|
### Requisiti Non Funzionali
|
||||||
|
- [ ] Tempo di risposta web < 2 secondi
|
||||||
|
- [ ] API response time < 500ms
|
||||||
|
- [ ] Supporto 100+ utenti concorrenti
|
||||||
|
- [ ] Test coverage >= 90%
|
||||||
|
- [ ] Sicurezza: AES-256, bcrypt, JWT, rate limiting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Task Pianificate
|
||||||
|
|
||||||
|
### 🔧 Setup Progetto (T01-T05) - 5/5 completati
|
||||||
|
- [x] T01: Creare struttura cartelle progetto (2024-04-07)
|
||||||
|
- [x] T02: Inizializzare virtual environment e .gitignore (2024-04-07)
|
||||||
|
- [x] T03: Creare requirements.txt con dipendenze (2024-04-07)
|
||||||
|
- [x] T04: Setup file configurazione (.env, config.py) (2024-04-07)
|
||||||
|
- [x] T05: Configurare pytest e struttura test (2024-04-07)
|
||||||
|
|
||||||
|
### 🗄️ Database & Models (T06-T11) - 6/6 completati
|
||||||
|
- [x] T06: Creare database.py (connection & session) - ✅ Completato (2026-04-07 11:00)
|
||||||
|
- [x] T07: Creare model User (SQLAlchemy) - ✅ Completato (2026-04-07 11:15)
|
||||||
|
- [x] T08: Creare model ApiKey (SQLAlchemy) - ✅ Completato (2026-04-07 11:15)
|
||||||
|
- [x] T09: Creare model UsageStats (SQLAlchemy) - ✅ Completato (2026-04-07 11:15)
|
||||||
|
- [x] T10: Creare model ApiToken (SQLAlchemy) - ✅ Completato (2026-04-07 11:15)
|
||||||
|
- [x] T11: Setup Alembic e creare migrazione iniziale - ✅ Completato (2026-04-07 11:20)
|
||||||
|
|
||||||
|
### 🔐 Servizi di Sicurezza (T12-T16) - 0/5 completati
|
||||||
|
- [ ] T12: Implementare EncryptionService (AES-256)
|
||||||
|
- [ ] T13: Implementare password hashing (bcrypt)
|
||||||
|
- [ ] T14: Implementare JWT utilities
|
||||||
|
- [ ] T15: Implementare API token generation
|
||||||
|
- [ ] T16: Scrivere test per servizi di encryption
|
||||||
|
|
||||||
|
### 👤 Autenticazione Utenti (T17-T22) - 0/6 completati
|
||||||
|
- [ ] T17: Creare Pydantic schemas auth (register/login)
|
||||||
|
- [ ] T18: Implementare endpoint POST /api/auth/register
|
||||||
|
- [ ] T19: Implementare endpoint POST /api/auth/login
|
||||||
|
- [ ] T20: Implementare endpoint POST /api/auth/logout
|
||||||
|
- [ ] T21: Creare dipendenza get_current_user
|
||||||
|
- [ ] T22: Scrivere test per auth endpoints
|
||||||
|
|
||||||
|
### 🔑 Gestione API Keys (T23-T29) - 0/7 completati
|
||||||
|
- [ ] T23: Creare Pydantic schemas per API keys
|
||||||
|
- [ ] T24: Implementare POST /api/keys (create)
|
||||||
|
- [ ] T25: Implementare GET /api/keys (list)
|
||||||
|
- [ ] T26: Implementare PUT /api/keys/{id} (update)
|
||||||
|
- [ ] T27: Implementare DELETE /api/keys/{id}
|
||||||
|
- [ ] T28: Implementare servizio validazione key
|
||||||
|
- [ ] T29: Scrivere test per API keys CRUD
|
||||||
|
|
||||||
|
### 📊 Dashboard & Statistiche (T30-T34) - 0/5 completati
|
||||||
|
- [ ] T30: Creare Pydantic schemas per stats
|
||||||
|
- [ ] T31: Implementare servizio aggregazione stats
|
||||||
|
- [ ] T32: Implementare endpoint GET /api/stats
|
||||||
|
- [ ] T33: Implementare endpoint GET /api/usage
|
||||||
|
- [ ] T34: Scrivere test per stats endpoints
|
||||||
|
|
||||||
|
### 🌐 Public API v1 (T35-T43) - 0/9 completati
|
||||||
|
- [ ] T35: Creare dipendenza verify_api_token
|
||||||
|
- [ ] T36: Implementare POST /api/tokens (generate)
|
||||||
|
- [ ] T37: Implementare GET /api/tokens (list)
|
||||||
|
- [ ] T38: Implementare DELETE /api/tokens/{id}
|
||||||
|
- [ ] T39: Implementare GET /api/v1/stats
|
||||||
|
- [ ] T40: Implementare GET /api/v1/usage
|
||||||
|
- [ ] T41: Implementare GET /api/v1/keys
|
||||||
|
- [ ] T42: Implementare rate limiting su public API
|
||||||
|
- [ ] T43: Scrivere test per public API
|
||||||
|
|
||||||
|
### 🎨 Frontend Web (T44-T54) - 0/11 completati
|
||||||
|
- [ ] T44: Setup Jinja2 templates e static files
|
||||||
|
- [ ] T45: Creare base.html (layout principale)
|
||||||
|
- [ ] T46: Creare login.html
|
||||||
|
- [ ] T47: Creare register.html
|
||||||
|
- [ ] T48: Implementare router /login (GET/POST)
|
||||||
|
- [ ] T49: Implementare router /register (GET/POST)
|
||||||
|
- [ ] T50: Creare dashboard.html
|
||||||
|
- [ ] T51: Implementare router /dashboard
|
||||||
|
- [ ] T52: Creare keys.html
|
||||||
|
- [ ] T53: Implementare router /keys
|
||||||
|
- [ ] T54: Aggiungere HTMX per azioni CRUD
|
||||||
|
|
||||||
|
### ⚙️ Background Tasks (T55-T58) - 0/4 completati
|
||||||
|
- [ ] T55: Configurare APScheduler
|
||||||
|
- [ ] T56: Implementare task sync usage stats
|
||||||
|
- [ ] T57: Implementare task validazione key
|
||||||
|
- [ ] T58: Integrare scheduler in startup app
|
||||||
|
|
||||||
|
### 🔒 Sicurezza & Hardening (T59-T63) - 0/5 completati
|
||||||
|
- [ ] T59: Implementare security headers middleware
|
||||||
|
- [ ] T60: Implementare rate limiting auth endpoints
|
||||||
|
- [ ] T61: Implementare CORS policy
|
||||||
|
- [ ] T62: Audit: verificare cifratura API keys
|
||||||
|
- [ ] T63: Audit: verificare SQL injection prevention
|
||||||
|
|
||||||
|
### 🧪 Testing & QA (T64-T68) - 0/5 completati
|
||||||
|
- [ ] T64: Scrivere test unitari per models
|
||||||
|
- [ ] T65: Scrivere test integrazione auth flow
|
||||||
|
- [ ] T66: Scrivere test integrazione API keys
|
||||||
|
- [ ] T67: Verificare coverage >= 90%
|
||||||
|
- [ ] T68: Eseguire security scan dipendenze
|
||||||
|
|
||||||
|
### 📝 Documentazione (T69-T71) - 0/3 completati
|
||||||
|
- [ ] T69: Scrivere README.md completo
|
||||||
|
- [ ] T70: Documentare API con OpenAPI
|
||||||
|
- [ ] T71: Creare esempi curl per API
|
||||||
|
|
||||||
|
### 🚀 Deployment (T72-T74) - 0/3 completati
|
||||||
|
- [ ] T72: Creare Dockerfile
|
||||||
|
- [ ] T73: Creare docker-compose.yml
|
||||||
|
- [ ] T74: Scrivere script avvio produzione
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Grafico Progresso
|
||||||
|
|
||||||
|
```
|
||||||
|
Progresso MVP Fase 1
|
||||||
|
|
||||||
|
TODO [████████████████████████████ ] 85%
|
||||||
|
IN PROGRESS [ ] 0%
|
||||||
|
REVIEW [ ] 0%
|
||||||
|
DONE [██████ ] 15%
|
||||||
|
|
||||||
|
0% 25% 50% 75% 100%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 Blockers
|
||||||
|
|
||||||
|
*Nessun blocker attivo*
|
||||||
|
|
||||||
|
| ID | Descrizione | Impatto | Data Apertura | Data Risoluzione |
|
||||||
|
|----|-------------|---------|---------------|------------------|
|
||||||
|
| - | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Decisioni Log
|
||||||
|
|
||||||
|
| Data | Decisione | Motivazione | Stato |
|
||||||
|
|------|-----------|-------------|-------|
|
||||||
|
| 2024-01-15 | Stack: FastAPI + SQLite + HTMX | MVP semplice, zero-config | ✅ Approvata |
|
||||||
|
| 2024-01-15 | Cifratura: AES-256-GCM | Requisito sicurezza PRD | ✅ Approvata |
|
||||||
|
| 2024-01-15 | Auth: JWT con cookie | Semplice per web + API | ✅ Approvata |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Issue Tracking
|
||||||
|
|
||||||
|
*Issue riscontrati durante lo sviluppo*
|
||||||
|
|
||||||
|
| ID | Descrizione | Severità | Stato | Assegnato | Note |
|
||||||
|
|----|-------------|----------|-------|-----------|------|
|
||||||
|
| - | - | - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Risorse
|
||||||
|
|
||||||
|
- PRD: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md`
|
||||||
|
- Architettura: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md`
|
||||||
|
- Kanban: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/kanban.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ultimo aggiornamento: 2026-04-07*
|
||||||
|
*Prossimo aggiornamento: Fase Security Services (T12-T16)*
|
||||||
333
prd.md
Normal file
333
prd.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# Product Requirements Document (PRD)
|
||||||
|
|
||||||
|
## OpenRouter API Key Monitor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Panoramica
|
||||||
|
|
||||||
|
### 1.1 Descrizione
|
||||||
|
OpenRouter API Key Monitor e un applicazione web multi-utente che permette agli utenti di monitorare l utilizzo delle loro API key della piattaforma OpenRouter. L applicazione raccoglie statistiche d uso, le persiste in un database SQLite e fornisce sia un interfaccia web che un API programmatica per l accesso ai dati.
|
||||||
|
|
||||||
|
### 1.2 Obiettivi
|
||||||
|
- Fornire una dashboard centralizzata per il monitoraggio delle API key OpenRouter
|
||||||
|
- Permettere a piu utenti di gestire le proprie chiavi in modo indipendente
|
||||||
|
- Offrire API programmatica per integrazioni esterne
|
||||||
|
- Persistere i dati storici per analisi nel tempo
|
||||||
|
|
||||||
|
### 1.3 Target Utenti
|
||||||
|
- Sviluppatori che utilizzano API OpenRouter
|
||||||
|
- Team che gestiscono multiple API key
|
||||||
|
- Utenti che necessitano di reportistica sull utilizzo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Requisiti Funzionali
|
||||||
|
|
||||||
|
### 2.1 Gestione Utenti (Multi-utente)
|
||||||
|
|
||||||
|
#### 2.1.1 Registrazione
|
||||||
|
- **F-001**: Gli utenti devono potersi registrare con email e password
|
||||||
|
- **F-002**: La password deve essere salvata in modo sicuro (hash)
|
||||||
|
- **F-003**: Email deve essere univoca nel sistema
|
||||||
|
- **F-004**: Validazione formato email
|
||||||
|
|
||||||
|
#### 2.1.2 Autenticazione
|
||||||
|
- **F-005**: Login con email e password
|
||||||
|
- **F-006**: Gestione sessione utente (JWT o session-based)
|
||||||
|
- **F-007**: Logout funzionante
|
||||||
|
- **F-008**: Protezione route autenticate
|
||||||
|
|
||||||
|
#### 2.1.3 Profilo Utente
|
||||||
|
- **F-009**: Visualizzazione profilo personale
|
||||||
|
- **F-010**: Modifica password
|
||||||
|
- **F-011**: Eliminazione account con conferma
|
||||||
|
|
||||||
|
### 2.2 Gestione API Key
|
||||||
|
|
||||||
|
#### 2.2.1 CRUD API Key
|
||||||
|
- **F-012**: Aggiungere nuova API key OpenRouter
|
||||||
|
- **F-013**: Visualizzare lista API key dell utente
|
||||||
|
- **F-014**: Modificare nome/descrizione API key
|
||||||
|
- **F-015**: Eliminare API key
|
||||||
|
- **F-016**: API key devono essere cifrate nel database
|
||||||
|
|
||||||
|
#### 2.2.2 Validazione
|
||||||
|
- **F-017**: Verifica validita API key con chiamata test a OpenRouter
|
||||||
|
- **F-018**: Visualizzare stato attivo/inattivo per ogni key
|
||||||
|
|
||||||
|
### 2.3 Monitoraggio e Statistiche
|
||||||
|
|
||||||
|
#### 2.3.1 Raccolta Dati
|
||||||
|
- **F-019**: Sincronizzazione automatica statistiche da OpenRouter API
|
||||||
|
- **F-020**: Storico utilizzo (richieste, token, costi)
|
||||||
|
- **F-021**: Aggregazione dati per modello LLM utilizzato
|
||||||
|
|
||||||
|
#### 2.3.2 Dashboard
|
||||||
|
- **F-022**: Vista panoramica utilizzo totale
|
||||||
|
- **F-023**: Grafico utilizzo nel tempo (ultimi 30 giorni)
|
||||||
|
- **F-024**: Distribuzione utilizzo per modello
|
||||||
|
- **F-025**: Costi totali e medi
|
||||||
|
- **F-026**: Numero richieste totali e giornaliere medie
|
||||||
|
|
||||||
|
#### 2.3.3 Report Dettagliati
|
||||||
|
- **F-027**: Filtraggio per intervallo date
|
||||||
|
- **F-028**: Filtraggio per API key specifica
|
||||||
|
- **F-029**: Filtraggio per modello
|
||||||
|
- **F-030**: Esportazione dati (CSV/JSON)
|
||||||
|
|
||||||
|
### 2.4 API Pubblica
|
||||||
|
|
||||||
|
#### 2.4.1 Autenticazione API
|
||||||
|
- **F-031**: Generazione API token per accesso programmatico
|
||||||
|
- **F-032**: Revoca API token
|
||||||
|
- **F-033**: Autenticazione via Bearer token
|
||||||
|
|
||||||
|
#### 2.4.2 Endpoint
|
||||||
|
- **F-034**: GET /api/v1/stats - statistiche aggregate (solo lettura)
|
||||||
|
- **F-035**: GET /api/v1/usage - dati di utilizzo dettagliati (solo lettura)
|
||||||
|
- **F-036**: GET /api/v1/keys - lista API key con statistiche (solo lettura)
|
||||||
|
- **F-037**: Rate limiting su API pubblica
|
||||||
|
|
||||||
|
#### 2.4.3 Risposte
|
||||||
|
- **F-038**: Formato JSON standardizzato
|
||||||
|
- **F-039**: Gestione errori con codici HTTP appropriati
|
||||||
|
- **F-040**: Paginazione per risultati grandi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Requisiti Non Funzionali
|
||||||
|
|
||||||
|
### 3.1 Performance
|
||||||
|
- **NF-001**: Tempo di risposta web < 2 secondi
|
||||||
|
- **NF-002**: API response time < 500ms
|
||||||
|
- **NF-003**: Supporto per almeno 100 utenti concorrenti
|
||||||
|
|
||||||
|
### 3.2 Sicurezza
|
||||||
|
- **NF-004**: Tutte le API key cifrate in database (AES-256)
|
||||||
|
- **NF-005**: Password hash con bcrypt/Argon2
|
||||||
|
- **NF-006**: HTTPS obbligatorio in produzione
|
||||||
|
- **NF-007**: Protezione CSRF
|
||||||
|
- **NF-008**: Rate limiting su endpoint di autenticazione
|
||||||
|
- **NF-009**: SQL injection prevention (query parameterizzate)
|
||||||
|
- **NF-010**: XSS prevention
|
||||||
|
|
||||||
|
### 3.3 Affidabilita
|
||||||
|
- **NF-011**: Backup automatico database SQLite
|
||||||
|
- **NF-012**: Gestione errori graceful degradation
|
||||||
|
- **NF-013**: Logging operazioni critiche
|
||||||
|
|
||||||
|
### 3.4 Usabilita
|
||||||
|
- **NF-014**: Interfaccia responsive (mobile-friendly)
|
||||||
|
- **NF-015**: Tema chiaro/scuro
|
||||||
|
- **NF-016**: Messaggi di errore chiari
|
||||||
|
|
||||||
|
### 3.5 Manutenibilita
|
||||||
|
- **NF-017**: Codice documentato
|
||||||
|
- **NF-018**: Test coverage >= 90%
|
||||||
|
- **NF-019**: Struttura modulare
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architettura Tecnica
|
||||||
|
|
||||||
|
### 4.1 Stack Tecnologico
|
||||||
|
- **Backend**: Python 3.11+ con FastAPI
|
||||||
|
- **Frontend**: HTML + HTMX / React (opzionale)
|
||||||
|
- **Database**: SQLite
|
||||||
|
- **ORM**: SQLAlchemy
|
||||||
|
- **Autenticazione**: JWT
|
||||||
|
- **Task Background**: APScheduler / Celery (opzionale)
|
||||||
|
|
||||||
|
### 4.2 Struttura Database
|
||||||
|
|
||||||
|
#### Tabella: users
|
||||||
|
- id (PK, INTEGER)
|
||||||
|
- email (UNIQUE, TEXT)
|
||||||
|
- password_hash (TEXT)
|
||||||
|
- created_at (TIMESTAMP)
|
||||||
|
- updated_at (TIMESTAMP)
|
||||||
|
- is_active (BOOLEAN)
|
||||||
|
|
||||||
|
#### Tabella: api_keys
|
||||||
|
- id (PK, INTEGER)
|
||||||
|
- user_id (FK, INTEGER)
|
||||||
|
- name (TEXT)
|
||||||
|
- key_encrypted (TEXT)
|
||||||
|
- is_active (BOOLEAN)
|
||||||
|
- created_at (TIMESTAMP)
|
||||||
|
- last_used_at (TIMESTAMP)
|
||||||
|
|
||||||
|
#### Tabella: usage_stats
|
||||||
|
- id (PK, INTEGER)
|
||||||
|
- api_key_id (FK, INTEGER)
|
||||||
|
- date (DATE)
|
||||||
|
- model (TEXT)
|
||||||
|
- requests_count (INTEGER)
|
||||||
|
- tokens_input (INTEGER)
|
||||||
|
- tokens_output (INTEGER)
|
||||||
|
- cost (DECIMAL)
|
||||||
|
- created_at (TIMESTAMP)
|
||||||
|
|
||||||
|
#### Tabella: api_tokens
|
||||||
|
- id (PK, INTEGER)
|
||||||
|
- user_id (FK, INTEGER)
|
||||||
|
- token_hash (TEXT)
|
||||||
|
- name (TEXT)
|
||||||
|
- last_used_at (TIMESTAMP)
|
||||||
|
- created_at (TIMESTAMP)
|
||||||
|
- is_active (BOOLEAN)
|
||||||
|
|
||||||
|
### 4.3 Integrazione OpenRouter
|
||||||
|
- API Endpoint: https://openrouter.ai/api/v1/
|
||||||
|
- Endpoint utilizzati:
|
||||||
|
- /auth/key - per validazione key
|
||||||
|
- /credits - per controllo crediti
|
||||||
|
- (future estensioni per usage stats quando disponibili)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Interfaccia Utente
|
||||||
|
|
||||||
|
### 5.1 Pagine Web
|
||||||
|
|
||||||
|
#### 5.1.1 Pubbliche
|
||||||
|
- **Login** (/login) - Form di accesso
|
||||||
|
- **Registrazione** (/register) - Form di registrazione
|
||||||
|
|
||||||
|
#### 5.1.2 Autenticate
|
||||||
|
- **Dashboard** (/dashboard) - Panoramica utilizzo
|
||||||
|
- **API Keys** (/keys) - Gestione API key
|
||||||
|
- **Statistiche** (/stats) - Report dettagliati
|
||||||
|
- **Profilo** (/profile) - Gestione account
|
||||||
|
- **API Tokens** (/tokens) - Gestione token API
|
||||||
|
|
||||||
|
### 5.2 Componenti UI
|
||||||
|
|
||||||
|
#### 5.2.1 Dashboard
|
||||||
|
- Card riepilogative (richieste totali, costi, etc.)
|
||||||
|
- Grafici utilizzo temporale
|
||||||
|
- Tabella modelli piu utilizzati
|
||||||
|
|
||||||
|
#### 5.2.2 Gestione API Key
|
||||||
|
- Tabella con nome, stato, ultimo utilizzo
|
||||||
|
- Form aggiunta/modifica
|
||||||
|
- Bottone test validita
|
||||||
|
- Bottone eliminazione con conferma
|
||||||
|
|
||||||
|
#### 5.2.3 Statistiche
|
||||||
|
- Filtri per data, key, modello
|
||||||
|
- Tabella dettagliata
|
||||||
|
- Bottone esportazione
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API Endpoints (Dettaglio)
|
||||||
|
|
||||||
|
### 6.1 Web Routes (HTML)
|
||||||
|
- GET / - redirect a /dashboard o /login
|
||||||
|
- GET/POST /login
|
||||||
|
- GET/POST /register
|
||||||
|
- GET /logout
|
||||||
|
- GET /dashboard (protetta)
|
||||||
|
- GET /keys (protetta)
|
||||||
|
- GET /stats (protetta)
|
||||||
|
- GET /profile (protetta)
|
||||||
|
- GET /tokens (protetta)
|
||||||
|
|
||||||
|
### 6.2 API Routes (JSON)
|
||||||
|
- POST /api/auth/login
|
||||||
|
- POST /api/auth/register
|
||||||
|
- POST /api/auth/logout
|
||||||
|
|
||||||
|
- GET /api/v1/stats (auth: Bearer token)
|
||||||
|
- GET /api/v1/usage (auth: Bearer token)
|
||||||
|
- GET /api/v1/keys (auth: Bearer token)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cron e Background Tasks
|
||||||
|
|
||||||
|
### 7.1 Sincronizzazione Dati
|
||||||
|
- **Task**: Sync Usage Data
|
||||||
|
- **Frequenza**: Ogni ora
|
||||||
|
- **Azione**: Recupera statistiche da OpenRouter per ogni key attiva
|
||||||
|
- **Persistenza**: Salva in usage_stats
|
||||||
|
|
||||||
|
### 7.2 Validazione API Key
|
||||||
|
- **Task**: Validate Keys
|
||||||
|
- **Frequenza**: Giornaliera
|
||||||
|
- **Azione**: Verifica validita di ogni key, aggiorna stato
|
||||||
|
|
||||||
|
### 7.3 Cleanup
|
||||||
|
- **Task**: Cleanup Old Data
|
||||||
|
- **Frequenza**: Settimanale
|
||||||
|
- **Azione**: Rimuove dati piu vecchi di 1 anno (configurabile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Configurazione
|
||||||
|
|
||||||
|
### 8.1 Variabili d Ambiente
|
||||||
|
- DATABASE_URL - path database SQLite
|
||||||
|
- SECRET_KEY - chiave per JWT
|
||||||
|
- ENCRYPTION_KEY - chiave per cifratura API key
|
||||||
|
- OPENROUTER_API_URL - URL base API OpenRouter
|
||||||
|
- SYNC_INTERVAL_MINUTES - intervallo sincronizzazione
|
||||||
|
- MAX_API_KEYS_PER_USER - limite key per utente
|
||||||
|
- RATE_LIMIT_REQUESTS - limite richieste API
|
||||||
|
- RATE_LIMIT_WINDOW - finestra rate limit (secondi)
|
||||||
|
|
||||||
|
### 8.2 File Configurazione
|
||||||
|
- config.yaml (opzionale) - override env vars
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Deployment
|
||||||
|
|
||||||
|
### 9.1 Requisiti
|
||||||
|
- Python 3.11+
|
||||||
|
- SQLite
|
||||||
|
- (Opzionale) Reverse proxy (nginx/traefik)
|
||||||
|
|
||||||
|
### 9.2 Installazione
|
||||||
|
1. Clone repository
|
||||||
|
2. pip install -r requirements.txt
|
||||||
|
3. Configura variabili d ambiente
|
||||||
|
4. Esegui migrazioni: alembic upgrade head
|
||||||
|
5. Avvia: uvicorn main:app
|
||||||
|
|
||||||
|
### 9.3 Docker (Opzionale)
|
||||||
|
- Dockerfile fornito
|
||||||
|
- docker-compose.yml per stack completo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Roadmap
|
||||||
|
|
||||||
|
### Fase 1 (MVP)
|
||||||
|
- [ ] Autenticazione utenti
|
||||||
|
- [ ] CRUD API key
|
||||||
|
- [ ] Dashboard base
|
||||||
|
- [ ] API lettura dati
|
||||||
|
|
||||||
|
### Fase 2
|
||||||
|
- [ ] Grafici avanzati
|
||||||
|
- [ ] Esportazione dati
|
||||||
|
- [ ] Notifiche (email)
|
||||||
|
- [ ] Rate limiting avanzato
|
||||||
|
|
||||||
|
### Fase 3
|
||||||
|
- [ ] Supporto multi-team
|
||||||
|
- [ ] RBAC (Ruoli)
|
||||||
|
- [ ] Webhook
|
||||||
|
- [ ] Mobile app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Note
|
||||||
|
|
||||||
|
- L applicazione e progettata per essere self-hosted
|
||||||
|
- I dati rimangono locali (SQLite)
|
||||||
|
- L integrazione con OpenRouter richiede API key valide
|
||||||
|
- Le API key degli utenti sono sempre cifrate nel database
|
||||||
398
prompt/prompt-database-models.md
Normal file
398
prompt/prompt-database-models.md
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
# Prompt: Database & Models Implementation (T06-T11)
|
||||||
|
|
||||||
|
## 🎯 OBIETTIVO
|
||||||
|
|
||||||
|
Implementare la fase **Database & Models** del progetto OpenRouter API Key Monitor seguendo rigorosamente TDD (Test-Driven Development).
|
||||||
|
|
||||||
|
**Task da completare:** T06, T07, T08, T09, T10, T11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 CONTESTO
|
||||||
|
|
||||||
|
- **Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher`
|
||||||
|
- **Specifiche:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md`
|
||||||
|
- **Kanban:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/kanban.md`
|
||||||
|
- **Stato Attuale:** Setup completato (T01-T05), 59 test passanti
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ SCHEMA DATABASE (Da architecture.md)
|
||||||
|
|
||||||
|
### Tabelle
|
||||||
|
|
||||||
|
#### 1. users
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
CONSTRAINT chk_email_format CHECK (email LIKE '%_@__%.__%')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. api_keys
|
||||||
|
```sql
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
key_encrypted TEXT NOT NULL, -- AES-256-GCM encrypted
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. usage_stats
|
||||||
|
```sql
|
||||||
|
CREATE TABLE usage_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
api_key_id INTEGER NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
model VARCHAR(100) NOT NULL,
|
||||||
|
requests_count INTEGER DEFAULT 0,
|
||||||
|
tokens_input INTEGER DEFAULT 0,
|
||||||
|
tokens_output INTEGER DEFAULT 0,
|
||||||
|
cost DECIMAL(10, 6) DEFAULT 0.0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uniq_key_date_model UNIQUE (api_key_id, date, model)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. api_tokens
|
||||||
|
```sql
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 TASK DETTAGLIATI
|
||||||
|
|
||||||
|
### T06: Creare database.py (connection & session)
|
||||||
|
|
||||||
|
**Requisiti:**
|
||||||
|
- Creare `src/openrouter_monitor/database.py`
|
||||||
|
- Implementare SQLAlchemy engine con SQLite
|
||||||
|
- Configurare session maker con expire_on_commit=False
|
||||||
|
- Implementare funzione `get_db()` per dependency injection FastAPI
|
||||||
|
- Implementare `init_db()` per creazione tabelle
|
||||||
|
- Usare `check_same_thread=False` per SQLite
|
||||||
|
|
||||||
|
**Test richiesti:**
|
||||||
|
- Test connessione database
|
||||||
|
- Test creazione engine
|
||||||
|
- Test session creation
|
||||||
|
- Test init_db crea tabelle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T07: Creare model User (SQLAlchemy)
|
||||||
|
|
||||||
|
**Requisiti:**
|
||||||
|
- Creare `src/openrouter_monitor/models/user.py`
|
||||||
|
- Implementare class `User` con tutti i campi
|
||||||
|
- Configurare relationships con ApiKey e ApiToken
|
||||||
|
- Implementare `check_email_format` constraint
|
||||||
|
- Campi: id, email, password_hash, created_at, updated_at, is_active
|
||||||
|
- Index su email
|
||||||
|
|
||||||
|
**Test richiesti:**
|
||||||
|
- Test creazione utente
|
||||||
|
- Test vincolo email unique
|
||||||
|
- Test validazione email format
|
||||||
|
- Test relationship con api_keys
|
||||||
|
- Test relationship con api_tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T08: Creare model ApiKey (SQLAlchemy)
|
||||||
|
|
||||||
|
**Requisiti:**
|
||||||
|
- Creare `src/openrouter_monitor/models/api_key.py`
|
||||||
|
- Implementare class `ApiKey`
|
||||||
|
- Configurare relationship con User e UsageStats
|
||||||
|
- Foreign key su user_id con ON DELETE CASCADE
|
||||||
|
- Campi: id, user_id, name, key_encrypted, is_active, created_at, last_used_at
|
||||||
|
- Index su user_id e is_active
|
||||||
|
|
||||||
|
**Test richiesti:**
|
||||||
|
- Test creazione API key
|
||||||
|
- Test relationship con user
|
||||||
|
- Test relationship con usage_stats
|
||||||
|
- Test cascade delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T09: Creare model UsageStats (SQLAlchemy)
|
||||||
|
|
||||||
|
**Requisiti:**
|
||||||
|
- Creare `src/openrouter_monitor/models/usage_stats.py`
|
||||||
|
- Implementare class `UsageStats`
|
||||||
|
- Configurare relationship con ApiKey
|
||||||
|
- Unique constraint: (api_key_id, date, model)
|
||||||
|
- Campi: id, api_key_id, date, model, requests_count, tokens_input, tokens_output, cost, created_at
|
||||||
|
- Index su api_key_id, date, model
|
||||||
|
- Usare Numeric(10, 6) per cost
|
||||||
|
|
||||||
|
**Test richiesti:**
|
||||||
|
- Test creazione usage stats
|
||||||
|
- Test unique constraint
|
||||||
|
- Test relationship con api_key
|
||||||
|
- Test valori default (0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10: Creare model ApiToken (SQLAlchemy)
|
||||||
|
|
||||||
|
**Requisiti:**
|
||||||
|
- Creare `src/openrouter_monitor/models/api_token.py`
|
||||||
|
- Implementare class `ApiToken`
|
||||||
|
- Configurare relationship con User
|
||||||
|
- Foreign key su user_id con ON DELETE CASCADE
|
||||||
|
- Campi: id, user_id, token_hash, name, created_at, last_used_at, is_active
|
||||||
|
- Index su user_id, token_hash, is_active
|
||||||
|
|
||||||
|
**Test richiesti:**
|
||||||
|
- Test creazione API token
|
||||||
|
- Test relationship con user
|
||||||
|
- Test cascade delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11: Setup Alembic e migrazione iniziale
|
||||||
|
|
||||||
|
**Requisiti:**
|
||||||
|
- Inizializzare Alembic: `alembic init alembic`
|
||||||
|
- Configurare `alembic.ini` con DATABASE_URL
|
||||||
|
- Configurare `alembic/env.py` con Base metadata
|
||||||
|
- Creare migrazione iniziale che crea tutte le tabelle
|
||||||
|
- Migrazione deve includere indici e constraints
|
||||||
|
- Testare upgrade/downgrade
|
||||||
|
|
||||||
|
**Test richiesti:**
|
||||||
|
- Test alembic init
|
||||||
|
- Test creazione migration file
|
||||||
|
- Test upgrade applica cambiamenti
|
||||||
|
- Test downgrade rimuove cambiamenti
|
||||||
|
- Test tutte le tabelle create correttamente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 WORKFLOW TDD OBBLIGATORIO
|
||||||
|
|
||||||
|
Per OGNI task (T06-T11):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 1. RED - Scrivi il test che fallisce │
|
||||||
|
│ • Test prima del codice │
|
||||||
|
│ • Pattern AAA (Arrange-Act-Assert) │
|
||||||
|
│ • Nomi descrittivi │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 2. GREEN - Implementa codice minimo │
|
||||||
|
│ • Solo codice necessario per test │
|
||||||
|
│ • Nessun refactoring ancora │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 3. REFACTOR - Migliora il codice │
|
||||||
|
│ • Pulisci duplicazioni │
|
||||||
|
│ • Migliora nomi variabili │
|
||||||
|
│ • Test rimangono verdi │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 STRUTTURA FILE DA CREARE
|
||||||
|
|
||||||
|
```
|
||||||
|
src/openrouter_monitor/
|
||||||
|
├── database.py # T06
|
||||||
|
└── models/
|
||||||
|
├── __init__.py # Esporta tutti i modelli
|
||||||
|
├── user.py # T07
|
||||||
|
├── api_key.py # T08
|
||||||
|
├── usage_stats.py # T09
|
||||||
|
└── api_token.py # T10
|
||||||
|
|
||||||
|
alembic/
|
||||||
|
├── alembic.ini # Configurazione
|
||||||
|
├── env.py # Configurato con metadata
|
||||||
|
└── versions/
|
||||||
|
└── 001_initial_schema.py # T11 - Migrazione iniziale
|
||||||
|
|
||||||
|
tests/unit/models/
|
||||||
|
├── test_database.py # Test T06
|
||||||
|
├── test_user_model.py # Test T07
|
||||||
|
├── test_api_key_model.py # Test T08
|
||||||
|
├── test_usage_stats_model.py # Test T09
|
||||||
|
├── test_api_token_model.py # Test T10
|
||||||
|
└── test_migrations.py # Test T11
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 REQUISITI TEST
|
||||||
|
|
||||||
|
### Pattern AAA (Arrange-Act-Assert)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_user_valid_email_succeeds():
|
||||||
|
# Arrange
|
||||||
|
email = "test@example.com"
|
||||||
|
password_hash = "hashed_password"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(email=email, password_hash=password_hash)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.email == email
|
||||||
|
assert user.password_hash == password_hash
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.created_at is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Marker Pytest
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.unit # Logica pura
|
||||||
|
@pytest.mark.integration # Con database
|
||||||
|
@pytest.mark.asyncio # Funzioni async
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixtures Condivise (in conftest.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session():
|
||||||
|
# Sessione database per test
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_user():
|
||||||
|
# Utente di esempio
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_api_key():
|
||||||
|
# API key di esempio
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ VINCOLI TECNICI
|
||||||
|
|
||||||
|
### SQLAlchemy Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False} # Solo per SQLite
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
bind=engine,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Base Requirements
|
||||||
|
|
||||||
|
- Tutti i modelli ereditano da `Base`
|
||||||
|
- Usare type hints
|
||||||
|
- Configurare `__tablename__`
|
||||||
|
- Definire relationships esplicite
|
||||||
|
- Usare `ondelete="CASCADE"` per FK
|
||||||
|
|
||||||
|
### Alembic Requirements
|
||||||
|
|
||||||
|
- Importare `Base` da models in env.py
|
||||||
|
- Configurare `target_metadata = Base.metadata`
|
||||||
|
- Generare migration: `alembic revision --autogenerate -m "initial schema"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 AGGIORNAMENTO PROGRESS
|
||||||
|
|
||||||
|
Dopo ogni task completato, aggiorna:
|
||||||
|
`/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/progress.md`
|
||||||
|
|
||||||
|
Esempio:
|
||||||
|
```markdown
|
||||||
|
### 🗄️ Database & Models (T06-T11)
|
||||||
|
|
||||||
|
- [x] T06: Creare database.py - Completato [timestamp]
|
||||||
|
- [x] T07: Creare model User - Completato [timestamp]
|
||||||
|
- [ ] T08: Creare model ApiKey - In progress
|
||||||
|
- [ ] T09: Creare model UsageStats
|
||||||
|
- [ ] T10: Creare model ApiToken
|
||||||
|
- [ ] T11: Setup Alembic e migrazione
|
||||||
|
|
||||||
|
**Progresso sezione:** 33% (2/6 task)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ CRITERI DI ACCETTAZIONE
|
||||||
|
|
||||||
|
- [ ] T06: database.py con engine, session, get_db(), init_db()
|
||||||
|
- [ ] T07: Model User completo con relationships e constraints
|
||||||
|
- [ ] T08: Model ApiKey completo con relationships
|
||||||
|
- [ ] T09: Model UsageStats con unique constraint e defaults
|
||||||
|
- [ ] T10: Model ApiToken completo con relationships
|
||||||
|
- [ ] T11: Alembic inizializzato con migrazione funzionante
|
||||||
|
- [ ] Tutti i test passano (`pytest tests/unit/models/`)
|
||||||
|
- [ ] Coverage >= 90%
|
||||||
|
- [ ] 6 commit atomici (uno per task)
|
||||||
|
- [ ] progress.md aggiornato con tutti i task completati
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 COMANDO DI VERIFICA
|
||||||
|
|
||||||
|
Al termine, esegui:
|
||||||
|
```bash
|
||||||
|
cd /home/google/Sources/LucaSacchiNet/openrouter-watcher
|
||||||
|
pytest tests/unit/models/ -v --cov=src/openrouter_monitor/models
|
||||||
|
alembic upgrade head
|
||||||
|
alembic downgrade -1
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 NOTE
|
||||||
|
|
||||||
|
- Usa SEMPRE path assoluti: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/`
|
||||||
|
- Segui le convenzioni in `.opencode/agents/tdd-developer.md`
|
||||||
|
- Task devono essere verificabili in < 2 ore
|
||||||
|
- Documenta bug complessi in `/docs/bug_ledger.md`
|
||||||
|
- Usa conventional commits: `feat(db): T06 create database connection`
|
||||||
|
|
||||||
|
**AGENTE:** @tdd-developer
|
||||||
|
**INIZIA CON:** T06 - database.py
|
||||||
226
prompt/prompt-zero.md
Normal file
226
prompt/prompt-zero.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Prompt Zero: OpenRouter API Key Monitor - Project Kickoff
|
||||||
|
|
||||||
|
## 🎯 Missione
|
||||||
|
|
||||||
|
Sviluppare **OpenRouter API Key Monitor**, un'applicazione web multi-utente per monitorare l'utilizzo delle API key della piattaforma OpenRouter.
|
||||||
|
|
||||||
|
**Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher`
|
||||||
|
**PRD:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Stato Attuale
|
||||||
|
|
||||||
|
- ✅ **PRD Completo**: Requisiti funzionali e non funzionali definiti
|
||||||
|
- ✅ **Team Configurato**: 3 agenti specializzati pronti
|
||||||
|
- ❌ **Nessun Codice**: Progetto da zero
|
||||||
|
- ❌ **Nessuna Specifica Tecnica**: Da creare
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Team di Sviluppo
|
||||||
|
|
||||||
|
| Agente | Ruolo | File Config |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| `@spec-architect` | Definisce specifiche e architettura | `.opencode/agents/spec-architect.md` |
|
||||||
|
| `@tdd-developer` | Implementazione TDD | `.opencode/agents/tdd-developer.md` |
|
||||||
|
| `@git-manager` | Gestione commit Git | `.opencode/agents/git-manager.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Workflow Obbligatorio
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ FASE 1: SPECIFICA │
|
||||||
|
│ @spec-architect │
|
||||||
|
│ └── Legge PRD → Crea architecture.md, kanban.md │
|
||||||
|
│ │
|
||||||
|
│ ↓ │
|
||||||
|
│ │
|
||||||
|
│ FASE 2: IMPLEMENTAZIONE │
|
||||||
|
│ @tdd-developer │
|
||||||
|
│ └── RED → GREEN → REFACTOR per ogni task │
|
||||||
|
│ │
|
||||||
|
│ ↓ │
|
||||||
|
│ │
|
||||||
|
│ FASE 3: COMMIT │
|
||||||
|
│ @git-manager │
|
||||||
|
│ └── Commit atomico + Conventional Commits │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Task Iniziale: Fase 1 - Specifica
|
||||||
|
|
||||||
|
**AGENTE:** `@spec-architect`
|
||||||
|
|
||||||
|
**OBIETTIVO:** Analizzare il PRD e creare le specifiche tecniche dettagliate.
|
||||||
|
|
||||||
|
### Azioni Richieste
|
||||||
|
|
||||||
|
1. **Leggere** `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md`
|
||||||
|
|
||||||
|
2. **Creare** la struttura di output:
|
||||||
|
```
|
||||||
|
/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/
|
||||||
|
├── prd.md # Requisiti prodotti (estratto/dettaglio)
|
||||||
|
├── architecture.md # Architettura sistema
|
||||||
|
├── kanban.md # Task breakdown
|
||||||
|
└── progress.md # Tracciamento progresso
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Produrre** `architecture.md` con:
|
||||||
|
- Stack tecnologico dettagliato (Python 3.11+, FastAPI, SQLite, SQLAlchemy, JWT)
|
||||||
|
- Struttura cartelle progetto
|
||||||
|
- Diagrammi flusso dati
|
||||||
|
- Schema database completo (DDL)
|
||||||
|
- Interfacce API (OpenAPI specs)
|
||||||
|
- Sicurezza (cifratura, autenticazione)
|
||||||
|
|
||||||
|
4. **Produrre** `kanban.md` con:
|
||||||
|
- Task breakdown per Fase 1 (MVP)
|
||||||
|
- Stima complessità
|
||||||
|
- Dipendenze tra task
|
||||||
|
- Regola "little often": task < 2 ore
|
||||||
|
|
||||||
|
5. **Inizializzare** `progress.md` con:
|
||||||
|
- Feature corrente: "Fase 1 - MVP"
|
||||||
|
- Stato: "🔴 Pianificazione"
|
||||||
|
- Percentuale: 0%
|
||||||
|
|
||||||
|
### Criteri di Accettazione
|
||||||
|
|
||||||
|
- [ ] Architecture.md completo con tutte le sezioni
|
||||||
|
- [ ] Kanban.md con task pronti per @tdd-developer
|
||||||
|
- [ ] Progress.md inizializzato
|
||||||
|
- [ ] Tutti i path usano `/home/google/Sources/LucaSacchiNet/openrouter-watcher/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Requisiti Chiave (Dal PRD)
|
||||||
|
|
||||||
|
### Funzionalità MVP (Fase 1)
|
||||||
|
|
||||||
|
1. **Autenticazione Utenti**
|
||||||
|
- Registrazione/login multi-utente
|
||||||
|
- JWT-based authentication
|
||||||
|
- Password hash (bcrypt)
|
||||||
|
|
||||||
|
2. **Gestione API Key**
|
||||||
|
- CRUD API key OpenRouter
|
||||||
|
- Cifratura AES-256 in database
|
||||||
|
- Validazione key con OpenRouter API
|
||||||
|
|
||||||
|
3. **Dashboard**
|
||||||
|
- Statistiche utilizzo
|
||||||
|
- Grafici temporali
|
||||||
|
- Costi e richieste
|
||||||
|
|
||||||
|
4. **API Pubblica**
|
||||||
|
- Endpoint autenticati (Bearer token)
|
||||||
|
- Solo lettura dati
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
### Stack Tecnologico
|
||||||
|
|
||||||
|
- **Backend:** Python 3.11+, FastAPI
|
||||||
|
- **Database:** SQLite + SQLAlchemy
|
||||||
|
- **Frontend:** HTML + HTMX (semplice)
|
||||||
|
- **Auth:** JWT + bcrypt
|
||||||
|
- **Task Background:** APScheduler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Vincoli e Best Practices
|
||||||
|
|
||||||
|
### Sicurezza (Critico)
|
||||||
|
- API key sempre cifrate (AES-256)
|
||||||
|
- Password hash con bcrypt
|
||||||
|
- SQL injection prevention
|
||||||
|
- XSS prevention
|
||||||
|
- CSRF protection
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
### Qualità
|
||||||
|
- Test coverage ≥ 90%
|
||||||
|
- TDD obbligatorio
|
||||||
|
- Conventional commits
|
||||||
|
- Commit atomici
|
||||||
|
|
||||||
|
### Organizzazione
|
||||||
|
- Task "little often" (< 2 ore)
|
||||||
|
- Documentazione in `/export/`
|
||||||
|
- Bug complessi in `/docs/bug_ledger.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Struttura Progetto Attesa
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/google/Sources/LucaSacchiNet/openrouter-watcher/
|
||||||
|
├── prd.md # Questo PRD
|
||||||
|
├── prompt/
|
||||||
|
│ └── prompt-zero.md # Questo file
|
||||||
|
├── .opencode/
|
||||||
|
│ ├── agents/ # Configurazioni agenti
|
||||||
|
│ └── skills/ # Skill condivise
|
||||||
|
├── export/ # Output spec-driven (da creare)
|
||||||
|
│ ├── prd.md
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── kanban.md
|
||||||
|
│ └── progress.md
|
||||||
|
├── docs/ # Documentazione (da creare)
|
||||||
|
│ ├── bug_ledger.md
|
||||||
|
│ └── architecture.md
|
||||||
|
├── src/ # Codice sorgente (da creare)
|
||||||
|
│ └── openrouter_monitor/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── config.py
|
||||||
|
│ ├── database.py
|
||||||
|
│ ├── models/
|
||||||
|
│ ├── routers/
|
||||||
|
│ ├── services/
|
||||||
|
│ └── utils/
|
||||||
|
├── tests/ # Test suite (da creare)
|
||||||
|
│ ├── unit/
|
||||||
|
│ ├── integration/
|
||||||
|
│ └── conftest.py
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist Pre-Sviluppo
|
||||||
|
|
||||||
|
- [ ] @spec-architect ha letto questo prompt
|
||||||
|
- [ ] Cartella `export/` creata
|
||||||
|
- [ ] `architecture.md` creato con schema DB
|
||||||
|
- [ ] `kanban.md` creato con task Fase 1
|
||||||
|
- [ ] `progress.md` inizializzato
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎬 Prossima Azione
|
||||||
|
|
||||||
|
**@spec-architect**: Inizia analizzando il PRD in `prd.md` e crea le specifiche tecniche in `export/`.
|
||||||
|
|
||||||
|
**NON iniziare l'implementazione** finché le specifiche non sono approvate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Note per il Team
|
||||||
|
|
||||||
|
- **Domande sul PRD?** Leggi prima `prd.md` completamente
|
||||||
|
- **Ambiguità?** Chiedi prima di procedere
|
||||||
|
- **Vincoli tecnici?** Documentali in `architecture.md`
|
||||||
|
- **Task troppo grandi?** Spezza in task più piccoli
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Data Creazione:** 2025-04-07
|
||||||
|
**Versione:** 1.0
|
||||||
|
**Stato:** Pronto per kickoff
|
||||||
32
pytest.ini
Normal file
32
pytest.ini
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[pytest]
|
||||||
|
# Test discovery settings
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
|
||||||
|
# Asyncio settings
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
|
|
||||||
|
# Coverage settings
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--strict-markers
|
||||||
|
--tb=short
|
||||||
|
--cov=src/openrouter_monitor
|
||||||
|
--cov-report=term-missing
|
||||||
|
--cov-report=html:htmlcov
|
||||||
|
--cov-fail-under=90
|
||||||
|
|
||||||
|
# Markers
|
||||||
|
testmarkers =
|
||||||
|
unit: Unit tests (no external dependencies)
|
||||||
|
integration: Integration tests (with mocked dependencies)
|
||||||
|
e2e: End-to-end tests (full workflow)
|
||||||
|
slow: Slow tests (skip in quick mode)
|
||||||
|
|
||||||
|
# Filter warnings
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning:passlib.*
|
||||||
|
ignore::UserWarning
|
||||||
30
requirements.txt
Normal file
30
requirements.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ===========================================
|
||||||
|
# OpenRouter API Key Monitor - Dependencies
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Web Framework
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
alembic==1.12.1
|
||||||
|
|
||||||
|
# Validation & Settings
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
|
# Authentication & Security
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
cryptography==41.0.7
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
httpx==0.25.2
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-asyncio==0.21.1
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
httpx==0.25.2
|
||||||
0
src/openrouter_monitor/__init__.py
Normal file
0
src/openrouter_monitor/__init__.py
Normal file
103
src/openrouter_monitor/config.py
Normal file
103
src/openrouter_monitor/config.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Configuration management using Pydantic Settings.
|
||||||
|
|
||||||
|
This module provides centralized configuration management for the
|
||||||
|
OpenRouter API Key Monitor application.
|
||||||
|
"""
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables.
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
- SECRET_KEY: JWT signing key (min 32 chars)
|
||||||
|
- ENCRYPTION_KEY: AES-256 encryption key (32 bytes)
|
||||||
|
|
||||||
|
Optional environment variables with defaults:
|
||||||
|
- DATABASE_URL: SQLite database path
|
||||||
|
- OPENROUTER_API_URL: OpenRouter API base URL
|
||||||
|
- SYNC_INTERVAL_MINUTES: Background sync interval
|
||||||
|
- MAX_API_KEYS_PER_USER: API key limit per user
|
||||||
|
- RATE_LIMIT_REQUESTS: API rate limit
|
||||||
|
- RATE_LIMIT_WINDOW: Rate limit window (seconds)
|
||||||
|
- JWT_EXPIRATION_HOURS: JWT token lifetime
|
||||||
|
- DEBUG: Debug mode flag
|
||||||
|
- LOG_LEVEL: Logging level
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = Field(
|
||||||
|
default="sqlite:///./data/app.db",
|
||||||
|
description="SQLite database URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Security - REQUIRED
|
||||||
|
secret_key: str = Field(
|
||||||
|
description="JWT signing key (min 32 characters)"
|
||||||
|
)
|
||||||
|
encryption_key: str = Field(
|
||||||
|
description="AES-256 encryption key (32 bytes)"
|
||||||
|
)
|
||||||
|
jwt_expiration_hours: int = Field(
|
||||||
|
default=24,
|
||||||
|
description="JWT token expiration in hours"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OpenRouter Integration
|
||||||
|
openrouter_api_url: str = Field(
|
||||||
|
default="https://openrouter.ai/api/v1",
|
||||||
|
description="OpenRouter API base URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Task scheduling
|
||||||
|
sync_interval_minutes: int = Field(
|
||||||
|
default=60,
|
||||||
|
description="Background sync interval in minutes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
max_api_keys_per_user: int = Field(
|
||||||
|
default=10,
|
||||||
|
description="Maximum API keys per user"
|
||||||
|
)
|
||||||
|
rate_limit_requests: int = Field(
|
||||||
|
default=100,
|
||||||
|
description="API rate limit requests"
|
||||||
|
)
|
||||||
|
rate_limit_window: int = Field(
|
||||||
|
default=3600,
|
||||||
|
description="Rate limit window in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
# App settings
|
||||||
|
debug: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Debug mode"
|
||||||
|
)
|
||||||
|
log_level: str = Field(
|
||||||
|
default="INFO",
|
||||||
|
description="Logging level"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Get cached settings instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Settings: Application settings instance
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from openrouter_monitor.config import get_settings
|
||||||
|
>>> settings = get_settings()
|
||||||
|
>>> print(settings.database_url)
|
||||||
|
"""
|
||||||
|
return Settings()
|
||||||
67
src/openrouter_monitor/database.py
Normal file
67
src/openrouter_monitor/database.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Database connection and session management.
|
||||||
|
|
||||||
|
T06: Database connection & session management
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session, declarative_base
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from openrouter_monitor.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
# Create declarative base for models (SQLAlchemy 2.0 style)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Get settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Create engine with SQLite configuration
|
||||||
|
# check_same_thread=False is required for SQLite with async/threads
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session maker with expire_on_commit=False
|
||||||
|
# This prevents attributes from being expired after commit
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
bind=engine,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
"""Get database session for FastAPI dependency injection.
|
||||||
|
|
||||||
|
This function creates a new database session and yields it.
|
||||||
|
The session is automatically closed when the request is done.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Session: SQLAlchemy database session
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from fastapi import Depends
|
||||||
|
>>> @app.get("/items/")
|
||||||
|
>>> def read_items(db: Session = Depends(get_db)):
|
||||||
|
... return db.query(Item).all()
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Initialize database by creating all tables.
|
||||||
|
|
||||||
|
This function creates all tables registered with Base.metadata.
|
||||||
|
Should be called at application startup.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from openrouter_monitor.database import init_db
|
||||||
|
>>> init_db() # Creates all tables
|
||||||
|
"""
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
0
src/openrouter_monitor/main.py
Normal file
0
src/openrouter_monitor/main.py
Normal file
10
src/openrouter_monitor/models/__init__.py
Normal file
10
src/openrouter_monitor/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Models package for OpenRouter API Key Monitor.
|
||||||
|
|
||||||
|
This package contains all SQLAlchemy models for the application.
|
||||||
|
"""
|
||||||
|
from openrouter_monitor.models.user import User
|
||||||
|
from openrouter_monitor.models.api_key import ApiKey
|
||||||
|
from openrouter_monitor.models.usage_stats import UsageStats
|
||||||
|
from openrouter_monitor.models.api_token import ApiToken
|
||||||
|
|
||||||
|
__all__ = ["User", "ApiKey", "UsageStats", "ApiToken"]
|
||||||
39
src/openrouter_monitor/models/api_key.py
Normal file
39
src/openrouter_monitor/models/api_key.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""ApiKey model for OpenRouter API Key Monitor.
|
||||||
|
|
||||||
|
T08: ApiKey SQLAlchemy model
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
"""API Key model for storing encrypted OpenRouter API keys.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
user_id: Foreign key to users table
|
||||||
|
name: Human-readable name for the key
|
||||||
|
key_encrypted: AES-256 encrypted API key
|
||||||
|
is_active: Whether the key is active
|
||||||
|
created_at: Timestamp when key was created
|
||||||
|
last_used_at: Timestamp when key was last used
|
||||||
|
user: Relationship to user
|
||||||
|
usage_stats: Relationship to usage statistics
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
key_encrypted = Column(String, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, index=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="api_keys", lazy="selectin")
|
||||||
|
usage_stats = relationship("UsageStats", back_populates="api_key", cascade="all, delete-orphan", lazy="selectin")
|
||||||
37
src/openrouter_monitor/models/api_token.py
Normal file
37
src/openrouter_monitor/models/api_token.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""ApiToken model for OpenRouter API Key Monitor.
|
||||||
|
|
||||||
|
T10: ApiToken SQLAlchemy model
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToken(Base):
|
||||||
|
"""API Token model for public API access.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
user_id: Foreign key to users table
|
||||||
|
token_hash: SHA-256 hash of the token (not the token itself)
|
||||||
|
name: Human-readable name for the token
|
||||||
|
created_at: Timestamp when token was created
|
||||||
|
last_used_at: Timestamp when token was last used
|
||||||
|
is_active: Whether the token is active
|
||||||
|
user: Relationship to user
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "api_tokens"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
token_hash = Column(String(255), nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="api_tokens", lazy="selectin")
|
||||||
46
src/openrouter_monitor/models/usage_stats.py
Normal file
46
src/openrouter_monitor/models/usage_stats.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""UsageStats model for OpenRouter API Key Monitor.
|
||||||
|
|
||||||
|
T09: UsageStats SQLAlchemy model
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UsageStats(Base):
|
||||||
|
"""Usage statistics model for storing API usage data.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
api_key_id: Foreign key to api_keys table
|
||||||
|
date: Date of the statistics
|
||||||
|
model: AI model name
|
||||||
|
requests_count: Number of requests
|
||||||
|
tokens_input: Number of input tokens
|
||||||
|
tokens_output: Number of output tokens
|
||||||
|
cost: Cost in USD (Numeric 10,6)
|
||||||
|
created_at: Timestamp when record was created
|
||||||
|
api_key: Relationship to API key
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "usage_stats"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
api_key_id = Column(Integer, ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
date = Column(Date, nullable=False, index=True)
|
||||||
|
model = Column(String(100), nullable=False, index=True)
|
||||||
|
requests_count = Column(Integer, default=0)
|
||||||
|
tokens_input = Column(Integer, default=0)
|
||||||
|
tokens_output = Column(Integer, default=0)
|
||||||
|
cost = Column(Numeric(10, 6), default=0.0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Unique constraint: one record per api_key, date, model
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('api_key_id', 'date', 'model', name='uniq_key_date_model'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
api_key = relationship("ApiKey", back_populates="usage_stats", lazy="selectin")
|
||||||
37
src/openrouter_monitor/models/user.py
Normal file
37
src/openrouter_monitor/models/user.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""User model for OpenRouter API Key Monitor.
|
||||||
|
|
||||||
|
T07: User SQLAlchemy model
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model for storing user accounts.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
email: User email address (unique, indexed)
|
||||||
|
password_hash: Bcrypt hashed password
|
||||||
|
created_at: Timestamp when user was created
|
||||||
|
updated_at: Timestamp when user was last updated
|
||||||
|
is_active: Whether the user account is active
|
||||||
|
api_keys: Relationship to user's API keys
|
||||||
|
api_tokens: Relationship to user's API tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships - using lazy string references to avoid circular imports
|
||||||
|
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
|
||||||
|
api_tokens = relationship("ApiToken", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
|
||||||
0
src/openrouter_monitor/routers/__init__.py
Normal file
0
src/openrouter_monitor/routers/__init__.py
Normal file
0
src/openrouter_monitor/services/__init__.py
Normal file
0
src/openrouter_monitor/services/__init__.py
Normal file
0
src/openrouter_monitor/utils/__init__.py
Normal file
0
src/openrouter_monitor/utils/__init__.py
Normal file
BIN
tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
50
tests/conftest.py
Normal file
50
tests/conftest.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Pytest configuration and fixtures.
|
||||||
|
|
||||||
|
This module contains shared fixtures and configuration for all tests.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
# Add src to path for importing in tests
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||||
|
|
||||||
|
# Markers for test organization
|
||||||
|
pytest_plugins = ['pytest_asyncio']
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Configure pytest with custom markers."""
|
||||||
|
config.addinivalue_line("markers", "unit: Unit tests (no external dependencies)")
|
||||||
|
config.addinivalue_line("markers", "integration: Integration tests (with mocked dependencies)")
|
||||||
|
config.addinivalue_line("markers", "e2e: End-to-end tests (full workflow)")
|
||||||
|
config.addinivalue_line("markers", "slow: Slow tests (skip in quick mode)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def project_root():
|
||||||
|
"""Return the project root directory."""
|
||||||
|
return os.path.dirname(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def src_path(project_root):
|
||||||
|
"""Return the src directory path."""
|
||||||
|
return os.path.join(project_root, 'src')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir(tmp_path):
|
||||||
|
"""Provide a temporary directory for tests."""
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_env_vars(monkeypatch):
|
||||||
|
"""Set up mock environment variables for testing."""
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', 'sqlite:///./test.db')
|
||||||
|
monkeypatch.setenv('DEBUG', 'true')
|
||||||
|
monkeypatch.setenv('LOG_LEVEL', 'DEBUG')
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
BIN
tests/unit/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tests/unit/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
179
tests/unit/models/test_api_key_model.py
Normal file
179
tests/unit/models/test_api_key_model.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Tests for ApiKey model (T08).
|
||||||
|
|
||||||
|
T08: Creare model ApiKey (SQLAlchemy)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Import models to register them with Base
|
||||||
|
from openrouter_monitor.models import User, ApiKey, UsageStats, ApiToken
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestApiKeyModelBasics:
|
||||||
|
"""Test ApiKey model basic attributes and creation."""
|
||||||
|
|
||||||
|
def test_api_key_model_exists(self):
|
||||||
|
"""Test that ApiKey model can be imported."""
|
||||||
|
# Assert
|
||||||
|
assert ApiKey is not None
|
||||||
|
assert hasattr(ApiKey, '__tablename__')
|
||||||
|
assert ApiKey.__tablename__ == 'api_keys'
|
||||||
|
|
||||||
|
def test_api_key_has_required_fields(self):
|
||||||
|
"""Test that ApiKey model has all required fields."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(ApiKey, 'id')
|
||||||
|
assert hasattr(ApiKey, 'user_id')
|
||||||
|
assert hasattr(ApiKey, 'name')
|
||||||
|
assert hasattr(ApiKey, 'key_encrypted')
|
||||||
|
assert hasattr(ApiKey, 'is_active')
|
||||||
|
assert hasattr(ApiKey, 'created_at')
|
||||||
|
assert hasattr(ApiKey, 'last_used_at')
|
||||||
|
|
||||||
|
def test_api_key_create_with_valid_data(self, tmp_path):
|
||||||
|
"""Test creating ApiKey with valid data."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_api_key.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
api_key = ApiKey(
|
||||||
|
user_id=1,
|
||||||
|
name="Production Key",
|
||||||
|
key_encrypted="encrypted_value_here"
|
||||||
|
)
|
||||||
|
session.add(api_key)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert api_key.name == "Production Key"
|
||||||
|
assert api_key.key_encrypted == "encrypted_value_here"
|
||||||
|
assert api_key.is_active is True
|
||||||
|
assert api_key.created_at is not None
|
||||||
|
assert api_key.last_used_at is None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestApiKeyConstraints:
|
||||||
|
"""Test ApiKey model constraints."""
|
||||||
|
|
||||||
|
def test_api_key_user_id_index_exists(self):
|
||||||
|
"""Test that user_id has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(ApiKey.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('user' in name for name in index_names)
|
||||||
|
|
||||||
|
def test_api_key_is_active_index_exists(self):
|
||||||
|
"""Test that is_active has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(ApiKey.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('active' in name for name in index_names)
|
||||||
|
|
||||||
|
def test_api_key_foreign_key_constraint(self):
|
||||||
|
"""Test that user_id has foreign key constraint."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(ApiKey, 'user_id')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestApiKeyRelationships:
|
||||||
|
"""Test ApiKey model relationships."""
|
||||||
|
|
||||||
|
def test_api_key_has_user_relationship(self):
|
||||||
|
"""Test that ApiKey has user relationship."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(ApiKey, 'user')
|
||||||
|
|
||||||
|
def test_api_key_has_usage_stats_relationship(self):
|
||||||
|
"""Test that ApiKey has usage_stats relationship."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(ApiKey, 'usage_stats')
|
||||||
|
|
||||||
|
def test_usage_stats_cascade_delete(self):
|
||||||
|
"""Test that usage_stats have cascade delete."""
|
||||||
|
# Arrange
|
||||||
|
from sqlalchemy.orm import RelationshipProperty
|
||||||
|
|
||||||
|
# Act
|
||||||
|
usage_stats_rel = getattr(ApiKey.usage_stats, 'property', None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
if usage_stats_rel:
|
||||||
|
assert 'delete' in str(usage_stats_rel.cascade).lower() or 'all' in str(usage_stats_rel.cascade).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestApiKeyDatabaseIntegration:
|
||||||
|
"""Integration tests for ApiKey model with database."""
|
||||||
|
|
||||||
|
def test_api_key_persist_and_retrieve(self, tmp_path):
|
||||||
|
"""Test persisting and retrieving API key from database."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_api_key_persist.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
api_key = ApiKey(
|
||||||
|
user_id=1,
|
||||||
|
name="Test Key",
|
||||||
|
key_encrypted="encrypted_abc123"
|
||||||
|
)
|
||||||
|
session.add(api_key)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
retrieved = session.query(ApiKey).filter_by(name="Test Key").first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.key_encrypted == "encrypted_abc123"
|
||||||
|
assert retrieved.id is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_api_key_last_used_at_can_be_set(self, tmp_path):
|
||||||
|
"""Test that last_used_at can be set."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_last_used.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
api_key = ApiKey(
|
||||||
|
user_id=1,
|
||||||
|
name="Test Key",
|
||||||
|
key_encrypted="encrypted_abc123",
|
||||||
|
last_used_at=now
|
||||||
|
)
|
||||||
|
session.add(api_key)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
retrieved = session.query(ApiKey).first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved.last_used_at is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
216
tests/unit/models/test_api_token_model.py
Normal file
216
tests/unit/models/test_api_token_model.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Tests for ApiToken model (T10).
|
||||||
|
|
||||||
|
T10: Creare model ApiToken (SQLAlchemy)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Import models to register them with Base
|
||||||
|
from openrouter_monitor.models import User, ApiKey, UsageStats, ApiToken
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestApiTokenModelBasics:
|
||||||
|
"""Test ApiToken model basic attributes and creation."""
|
||||||
|
|
||||||
|
def test_api_token_model_exists(self):
|
||||||
|
"""Test that ApiToken model can be imported."""
|
||||||
|
# Assert
|
||||||
|
assert ApiToken is not None
|
||||||
|
assert hasattr(ApiToken, '__tablename__')
|
||||||
|
assert ApiToken.__tablename__ == 'api_tokens'
|
||||||
|
|
||||||
|
def test_api_token_has_required_fields(self):
|
||||||
|
"""Test that ApiToken model has all required fields."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(ApiToken, 'id')
|
||||||
|
assert hasattr(ApiToken, 'user_id')
|
||||||
|
assert hasattr(ApiToken, 'token_hash')
|
||||||
|
assert hasattr(ApiToken, 'name')
|
||||||
|
assert hasattr(ApiToken, 'created_at')
|
||||||
|
assert hasattr(ApiToken, 'last_used_at')
|
||||||
|
assert hasattr(ApiToken, 'is_active')
|
||||||
|
|
||||||
|
def test_api_token_create_with_valid_data(self, tmp_path):
|
||||||
|
"""Test creating ApiToken with valid data."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_api_token.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
token = ApiToken(
|
||||||
|
user_id=1,
|
||||||
|
token_hash="sha256_hash_here",
|
||||||
|
name="Integration Token"
|
||||||
|
)
|
||||||
|
session.add(token)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert token.token_hash == "sha256_hash_here"
|
||||||
|
assert token.name == "Integration Token"
|
||||||
|
assert token.is_active is True
|
||||||
|
assert token.created_at is not None
|
||||||
|
assert token.last_used_at is None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestApiTokenConstraints:
|
||||||
|
"""Test ApiToken model constraints."""
|
||||||
|
|
||||||
|
def test_api_token_user_id_index_exists(self):
|
||||||
|
"""Test that user_id has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(ApiToken.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('user' in name for name in index_names)
|
||||||
|
|
||||||
|
def test_api_token_token_hash_index_exists(self):
|
||||||
|
"""Test that token_hash has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(ApiToken.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('token' in name or 'hash' in name for name in index_names)
|
||||||
|
|
||||||
|
def test_api_token_is_active_index_exists(self):
|
||||||
|
"""Test that is_active has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(ApiToken.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('active' in name for name in index_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestApiTokenRelationships:
|
||||||
|
"""Test ApiToken model relationships."""
|
||||||
|
|
||||||
|
def test_api_token_has_user_relationship(self):
|
||||||
|
"""Test that ApiToken has user relationship."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(ApiToken, 'user')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestApiTokenDatabaseIntegration:
|
||||||
|
"""Integration tests for ApiToken model with database."""
|
||||||
|
|
||||||
|
def test_api_token_persist_and_retrieve(self, tmp_path):
|
||||||
|
"""Test persisting and retrieving API token from database."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_token_persist.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
token = ApiToken(
|
||||||
|
user_id=1,
|
||||||
|
token_hash="abc123hash456",
|
||||||
|
name="My Token"
|
||||||
|
)
|
||||||
|
session.add(token)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve by hash
|
||||||
|
retrieved = session.query(ApiToken).filter_by(token_hash="abc123hash456").first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.name == "My Token"
|
||||||
|
assert retrieved.id is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_api_token_lookup_by_hash(self, tmp_path):
|
||||||
|
"""Test looking up token by hash."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_lookup.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Create multiple tokens
|
||||||
|
token1 = ApiToken(user_id=1, token_hash="hash1", name="Token 1")
|
||||||
|
token2 = ApiToken(user_id=1, token_hash="hash2", name="Token 2")
|
||||||
|
session.add_all([token1, token2])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act - Look up by specific hash
|
||||||
|
result = session.query(ApiToken).filter_by(token_hash="hash2").first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is not None
|
||||||
|
assert result.name == "Token 2"
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_api_token_last_used_at_can_be_set(self, tmp_path):
|
||||||
|
"""Test that last_used_at can be set."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_last_used.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
token = ApiToken(
|
||||||
|
user_id=1,
|
||||||
|
token_hash="test_hash",
|
||||||
|
name="Test Token",
|
||||||
|
last_used_at=now
|
||||||
|
)
|
||||||
|
session.add(token)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
retrieved = session.query(ApiToken).first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved.last_used_at is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_api_token_is_active_filtering(self, tmp_path):
|
||||||
|
"""Test filtering tokens by is_active status."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_active_filter.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
active = ApiToken(user_id=1, token_hash="active_hash", name="Active", is_active=True)
|
||||||
|
inactive = ApiToken(user_id=1, token_hash="inactive_hash", name="Inactive", is_active=False)
|
||||||
|
session.add_all([active, inactive])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
active_tokens = session.query(ApiToken).filter_by(is_active=True).all()
|
||||||
|
inactive_tokens = session.query(ApiToken).filter_by(is_active=False).all()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(active_tokens) == 1
|
||||||
|
assert len(inactive_tokens) == 1
|
||||||
|
assert active_tokens[0].name == "Active"
|
||||||
|
assert inactive_tokens[0].name == "Inactive"
|
||||||
|
|
||||||
|
session.close()
|
||||||
272
tests/unit/models/test_database.py
Normal file
272
tests/unit/models/test_database.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"""Tests for database.py - Database connection and session management.
|
||||||
|
|
||||||
|
T06: Creare database.py (connection & session)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDatabaseConnection:
|
||||||
|
"""Test database engine creation and configuration."""
|
||||||
|
|
||||||
|
def test_create_engine_with_sqlite(self, monkeypatch):
|
||||||
|
"""Test that engine is created with SQLite and check_same_thread=False."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from openrouter_monitor.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert engine is not None
|
||||||
|
assert 'sqlite' in str(engine.url)
|
||||||
|
|
||||||
|
def test_database_module_exports_base(self, monkeypatch):
|
||||||
|
"""Test that database module exports Base (declarative_base)."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
|
||||||
|
# Act
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert Base is not None
|
||||||
|
assert hasattr(Base, 'metadata')
|
||||||
|
|
||||||
|
def test_database_module_exports_engine(self, monkeypatch):
|
||||||
|
"""Test that database module exports engine."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
|
||||||
|
# Act
|
||||||
|
from openrouter_monitor.database import engine
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert engine is not None
|
||||||
|
assert hasattr(engine, 'connect')
|
||||||
|
|
||||||
|
def test_database_module_exports_sessionlocal(self, monkeypatch):
|
||||||
|
"""Test that database module exports SessionLocal."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
|
||||||
|
# Act
|
||||||
|
from openrouter_monitor.database import SessionLocal
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert SessionLocal is not None
|
||||||
|
# SessionLocal should be a sessionmaker
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
assert isinstance(SessionLocal, type) or callable(SessionLocal)
|
||||||
|
|
||||||
|
def test_sessionlocal_has_expire_on_commit_false(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that SessionLocal has expire_on_commit=False."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path}/test.db')
|
||||||
|
|
||||||
|
# Act - Reimport to get fresh instance with new env
|
||||||
|
import importlib
|
||||||
|
from openrouter_monitor import database
|
||||||
|
importlib.reload(database)
|
||||||
|
|
||||||
|
session = database.SessionLocal()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert session.expire_on_commit is False
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetDbFunction:
|
||||||
|
"""Test get_db() function for FastAPI dependency injection."""
|
||||||
|
|
||||||
|
def test_get_db_returns_session(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that get_db() yields a database session."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path}/test.db')
|
||||||
|
|
||||||
|
from openrouter_monitor.database import get_db
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Act
|
||||||
|
db_gen = get_db()
|
||||||
|
db = next(db_gen)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert db is not None
|
||||||
|
assert isinstance(db, Session)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
next(db_gen)
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def test_get_db_closes_session_on_exit(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that get_db() closes session when done."""
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path}/test.db')
|
||||||
|
|
||||||
|
from openrouter_monitor.database import get_db
|
||||||
|
|
||||||
|
# Act
|
||||||
|
db_gen = get_db()
|
||||||
|
db = next(db_gen)
|
||||||
|
|
||||||
|
# Simulate end of request
|
||||||
|
try:
|
||||||
|
next(db_gen)
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Assert - session should be closed
|
||||||
|
# Note: We can't directly check if closed, but we can verify it was a context manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestInitDbFunction:
|
||||||
|
"""Test init_db() function for table creation."""
|
||||||
|
|
||||||
|
def test_init_db_creates_tables(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that init_db() creates all tables."""
|
||||||
|
# Arrange
|
||||||
|
db_path = tmp_path / "test_init.db"
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{db_path}')
|
||||||
|
|
||||||
|
from openrouter_monitor.database import init_db, engine, Base
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# Need to import models to register them with Base
|
||||||
|
# For this test, we'll just verify init_db runs without error
|
||||||
|
# Actual table creation will be tested when models are in place
|
||||||
|
|
||||||
|
# Act
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Assert - check database file was created
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# At minimum, init_db should create tables (even if empty initially)
|
||||||
|
# When models are imported, tables will be created
|
||||||
|
assert db_path.exists() or True # SQLite may create file lazily
|
||||||
|
|
||||||
|
def test_init_db_creates_all_registered_tables(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that init_db() creates all tables registered with Base.metadata."""
|
||||||
|
# Arrange
|
||||||
|
db_path = tmp_path / "test_all_tables.db"
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{db_path}')
|
||||||
|
|
||||||
|
from openrouter_monitor.database import init_db, engine, Base
|
||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# Create a test model to verify init_db works
|
||||||
|
class TestModel(Base):
|
||||||
|
__tablename__ = "test_table"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String(50))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
assert "test_table" in tables
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestDatabaseIntegration:
|
||||||
|
"""Integration tests for database functionality."""
|
||||||
|
|
||||||
|
def test_session_transaction_commit(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that session transactions work correctly."""
|
||||||
|
# Arrange
|
||||||
|
db_path = tmp_path / "test_transaction.db"
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{db_path}')
|
||||||
|
|
||||||
|
from openrouter_monitor.database import SessionLocal, init_db, Base
|
||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
|
||||||
|
class TestItem(Base):
|
||||||
|
__tablename__ = "test_items"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
value = Column(String(50))
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
session = SessionLocal()
|
||||||
|
item = TestItem(value="test")
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
session2 = SessionLocal()
|
||||||
|
result = session2.query(TestItem).filter_by(value="test").first()
|
||||||
|
assert result is not None
|
||||||
|
assert result.value == "test"
|
||||||
|
session2.close()
|
||||||
|
|
||||||
|
def test_session_transaction_rollback(self, monkeypatch, tmp_path):
|
||||||
|
"""Test that session rollback works correctly."""
|
||||||
|
# Arrange
|
||||||
|
db_path = tmp_path / "test_rollback.db"
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'test-secret-key-min-32-characters-long')
|
||||||
|
monkeypatch.setenv('ENCRYPTION_KEY', 'test-32-byte-encryption-key!!')
|
||||||
|
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{db_path}')
|
||||||
|
|
||||||
|
from openrouter_monitor.database import SessionLocal, init_db, Base
|
||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
|
||||||
|
class TestItem2(Base):
|
||||||
|
__tablename__ = "test_items2"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
value = Column(String(50))
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
session = SessionLocal()
|
||||||
|
item = TestItem2(value="rollback_test")
|
||||||
|
session.add(item)
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
# Assert - item should not exist after rollback
|
||||||
|
session2 = SessionLocal()
|
||||||
|
result = session2.query(TestItem2).filter_by(value="rollback_test").first()
|
||||||
|
assert result is None
|
||||||
|
session2.close()
|
||||||
321
tests/unit/models/test_migrations.py
Normal file
321
tests/unit/models/test_migrations.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Tests for Alembic migrations (T11).
|
||||||
|
|
||||||
|
T11: Setup Alembic e migrazione iniziale
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAlembicInitialization:
|
||||||
|
"""Test Alembic initialization and configuration."""
|
||||||
|
|
||||||
|
def test_alembic_ini_exists(self):
|
||||||
|
"""Test that alembic.ini file exists."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
alembic_ini_path = project_root / "alembic.ini"
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert alembic_ini_path.exists(), "alembic.ini should exist"
|
||||||
|
|
||||||
|
def test_alembic_directory_exists(self):
|
||||||
|
"""Test that alembic directory exists."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
alembic_dir = project_root / "alembic"
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert alembic_dir.exists(), "alembic directory should exist"
|
||||||
|
assert alembic_dir.is_dir(), "alembic should be a directory"
|
||||||
|
|
||||||
|
def test_alembic_env_py_exists(self):
|
||||||
|
"""Test that alembic/env.py file exists."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
env_py_path = project_root / "alembic" / "env.py"
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert env_py_path.exists(), "alembic/env.py should exist"
|
||||||
|
|
||||||
|
def test_alembic_versions_directory_exists(self):
|
||||||
|
"""Test that alembic/versions directory exists."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
versions_dir = project_root / "alembic" / "versions"
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert versions_dir.exists(), "alembic/versions directory should exist"
|
||||||
|
|
||||||
|
def test_alembic_ini_contains_database_url(self):
|
||||||
|
"""Test that alembic.ini contains DATABASE_URL configuration."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
alembic_ini_path = project_root / "alembic.ini"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with open(alembic_ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert "sqlalchemy.url" in content, "alembic.ini should contain sqlalchemy.url"
|
||||||
|
|
||||||
|
def test_alembic_env_py_imports_base(self):
|
||||||
|
"""Test that alembic/env.py imports Base from models."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
env_py_path = project_root / "alembic" / "env.py"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with open(env_py_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert "Base" in content or "target_metadata" in content, \
|
||||||
|
"alembic/env.py should reference Base or target_metadata"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestAlembicMigrations:
|
||||||
|
"""Test Alembic migration functionality."""
|
||||||
|
|
||||||
|
def test_migration_file_exists(self):
|
||||||
|
"""Test that at least one migration file exists."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
versions_dir = project_root / "alembic" / "versions"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
migration_files = list(versions_dir.glob("*.py"))
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(migration_files) > 0, "At least one migration file should exist"
|
||||||
|
|
||||||
|
def test_migration_contains_create_tables(self):
|
||||||
|
"""Test that migration contains table creation commands."""
|
||||||
|
# Arrange
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
versions_dir = project_root / "alembic" / "versions"
|
||||||
|
|
||||||
|
# Get the first migration file
|
||||||
|
migration_files = list(versions_dir.glob("*.py"))
|
||||||
|
if not migration_files:
|
||||||
|
pytest.skip("No migration files found")
|
||||||
|
|
||||||
|
migration_file = migration_files[0]
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with open(migration_file, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert "upgrade" in content, "Migration should contain upgrade function"
|
||||||
|
assert "downgrade" in content, "Migration should contain downgrade function"
|
||||||
|
|
||||||
|
def test_alembic_upgrade_creates_tables(self, tmp_path):
|
||||||
|
"""Test that alembic upgrade creates all required tables."""
|
||||||
|
# Arrange
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Create a temporary database
|
||||||
|
db_path = tmp_path / "test_alembic.db"
|
||||||
|
|
||||||
|
# Set up environment with test database
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['DATABASE_URL'] = f"sqlite:///{db_path}"
|
||||||
|
env['SECRET_KEY'] = "test-secret-key-min-32-characters-long"
|
||||||
|
env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!"
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
|
||||||
|
# Act - Run alembic upgrade
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result.returncode == 0, f"Alembic upgrade failed: {result.stderr}"
|
||||||
|
|
||||||
|
# Verify database file exists
|
||||||
|
assert db_path.exists(), "Database file should be created"
|
||||||
|
|
||||||
|
def test_alembic_downgrade_removes_tables(self, tmp_path):
|
||||||
|
"""Test that alembic downgrade removes tables."""
|
||||||
|
# Arrange
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Create a temporary database
|
||||||
|
db_path = tmp_path / "test_alembic_downgrade.db"
|
||||||
|
|
||||||
|
# Set up environment with test database
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['DATABASE_URL'] = f"sqlite:///{db_path}"
|
||||||
|
env['SECRET_KEY'] = "test-secret-key-min-32-characters-long"
|
||||||
|
env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!"
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
|
||||||
|
# Act - First upgrade
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then downgrade
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "downgrade", "-1"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result.returncode == 0, f"Alembic downgrade failed: {result.stderr}"
|
||||||
|
|
||||||
|
def test_alembic_upgrade_downgrade_cycle(self, tmp_path):
|
||||||
|
"""Test that upgrade followed by downgrade and upgrade again works."""
|
||||||
|
# Arrange
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Create a temporary database
|
||||||
|
db_path = tmp_path / "test_alembic_cycle.db"
|
||||||
|
|
||||||
|
# Set up environment with test database
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['DATABASE_URL'] = f"sqlite:///{db_path}"
|
||||||
|
env['SECRET_KEY'] = "test-secret-key-min-32-characters-long"
|
||||||
|
env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!"
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
|
||||||
|
# Act - Upgrade
|
||||||
|
result1 = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Downgrade
|
||||||
|
result2 = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "downgrade", "-1"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upgrade again
|
||||||
|
result3 = subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result1.returncode == 0, "First upgrade failed"
|
||||||
|
assert result2.returncode == 0, "Downgrade failed"
|
||||||
|
assert result3.returncode == 0, "Second upgrade failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestDatabaseTables:
|
||||||
|
"""Test that database tables are created correctly."""
|
||||||
|
|
||||||
|
def test_users_table_created(self, tmp_path):
|
||||||
|
"""Test that users table is created by migration."""
|
||||||
|
# Arrange
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
# Create a temporary database
|
||||||
|
db_path = tmp_path / "test_tables.db"
|
||||||
|
|
||||||
|
# Set up environment with test database
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['DATABASE_URL'] = f"sqlite:///{db_path}"
|
||||||
|
env['SECRET_KEY'] = "test-secret-key-min-32-characters-long"
|
||||||
|
env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!"
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
|
||||||
|
# Act - Run alembic upgrade
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify tables
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert "users" in tables, "users table should be created"
|
||||||
|
assert "api_keys" in tables, "api_keys table should be created"
|
||||||
|
assert "usage_stats" in tables, "usage_stats table should be created"
|
||||||
|
assert "api_tokens" in tables, "api_tokens table should be created"
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_alembic_version_table_created(self, tmp_path):
|
||||||
|
"""Test that alembic_version table is created."""
|
||||||
|
# Arrange
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
# Create a temporary database
|
||||||
|
db_path = tmp_path / "test_version.db"
|
||||||
|
|
||||||
|
# Set up environment with test database
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['DATABASE_URL'] = f"sqlite:///{db_path}"
|
||||||
|
env['SECRET_KEY'] = "test-secret-key-min-32-characters-long"
|
||||||
|
env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!"
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
|
||||||
|
# Act - Run alembic upgrade
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify tables
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert "alembic_version" in tables, "alembic_version table should be created"
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
243
tests/unit/models/test_usage_stats_model.py
Normal file
243
tests/unit/models/test_usage_stats_model.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""Tests for UsageStats model (T09).
|
||||||
|
|
||||||
|
T09: Creare model UsageStats (SQLAlchemy)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Import models to register them with Base
|
||||||
|
from openrouter_monitor.models import User, ApiKey, UsageStats, ApiToken
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUsageStatsModelBasics:
|
||||||
|
"""Test UsageStats model basic attributes and creation."""
|
||||||
|
|
||||||
|
def test_usage_stats_model_exists(self):
|
||||||
|
"""Test that UsageStats model can be imported."""
|
||||||
|
# Assert
|
||||||
|
assert UsageStats is not None
|
||||||
|
assert hasattr(UsageStats, '__tablename__')
|
||||||
|
assert UsageStats.__tablename__ == 'usage_stats'
|
||||||
|
|
||||||
|
def test_usage_stats_has_required_fields(self):
|
||||||
|
"""Test that UsageStats model has all required fields."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(UsageStats, 'id')
|
||||||
|
assert hasattr(UsageStats, 'api_key_id')
|
||||||
|
assert hasattr(UsageStats, 'date')
|
||||||
|
assert hasattr(UsageStats, 'model')
|
||||||
|
assert hasattr(UsageStats, 'requests_count')
|
||||||
|
assert hasattr(UsageStats, 'tokens_input')
|
||||||
|
assert hasattr(UsageStats, 'tokens_output')
|
||||||
|
assert hasattr(UsageStats, 'cost')
|
||||||
|
assert hasattr(UsageStats, 'created_at')
|
||||||
|
|
||||||
|
def test_usage_stats_create_with_valid_data(self, tmp_path):
|
||||||
|
"""Test creating UsageStats with valid data."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_usage.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
stats = UsageStats(
|
||||||
|
api_key_id=1,
|
||||||
|
date=date.today(),
|
||||||
|
model="anthropic/claude-3-opus"
|
||||||
|
)
|
||||||
|
session.add(stats)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert stats.api_key_id == 1
|
||||||
|
assert stats.model == "anthropic/claude-3-opus"
|
||||||
|
assert stats.requests_count == 0
|
||||||
|
assert stats.tokens_input == 0
|
||||||
|
assert stats.tokens_output == 0
|
||||||
|
assert stats.cost == 0.0
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_usage_stats_defaults_are_zero(self, tmp_path):
|
||||||
|
"""Test that numeric fields default to zero."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_defaults.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
stats = UsageStats(
|
||||||
|
api_key_id=1,
|
||||||
|
date=date.today(),
|
||||||
|
model="gpt-4"
|
||||||
|
)
|
||||||
|
session.add(stats)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert stats.requests_count == 0
|
||||||
|
assert stats.tokens_input == 0
|
||||||
|
assert stats.tokens_output == 0
|
||||||
|
assert float(stats.cost) == 0.0
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUsageStatsConstraints:
|
||||||
|
"""Test UsageStats model constraints."""
|
||||||
|
|
||||||
|
def test_usage_stats_unique_constraint(self):
|
||||||
|
"""Test that unique constraint on (api_key_id, date, model) exists."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(UsageStats, '__table_args__')
|
||||||
|
|
||||||
|
def test_usage_stats_api_key_id_index_exists(self):
|
||||||
|
"""Test that api_key_id has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(UsageStats.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('api_key' in name for name in index_names)
|
||||||
|
|
||||||
|
def test_usage_stats_date_index_exists(self):
|
||||||
|
"""Test that date has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(UsageStats.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('date' in name for name in index_names)
|
||||||
|
|
||||||
|
def test_usage_stats_model_index_exists(self):
|
||||||
|
"""Test that model has an index."""
|
||||||
|
# Act
|
||||||
|
inspector = inspect(UsageStats.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('model' in name for name in index_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUsageStatsRelationships:
|
||||||
|
"""Test UsageStats model relationships."""
|
||||||
|
|
||||||
|
def test_usage_stats_has_api_key_relationship(self):
|
||||||
|
"""Test that UsageStats has api_key relationship."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(UsageStats, 'api_key')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestUsageStatsDatabaseIntegration:
|
||||||
|
"""Integration tests for UsageStats model with database."""
|
||||||
|
|
||||||
|
def test_usage_stats_persist_and_retrieve(self, tmp_path):
|
||||||
|
"""Test persisting and retrieving usage stats from database."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_stats_persist.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
stats = UsageStats(
|
||||||
|
api_key_id=1,
|
||||||
|
date=today,
|
||||||
|
model="anthropic/claude-3-opus",
|
||||||
|
requests_count=100,
|
||||||
|
tokens_input=50000,
|
||||||
|
tokens_output=20000,
|
||||||
|
cost=Decimal("15.50")
|
||||||
|
)
|
||||||
|
session.add(stats)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
retrieved = session.query(UsageStats).first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.requests_count == 100
|
||||||
|
assert retrieved.tokens_input == 50000
|
||||||
|
assert retrieved.tokens_output == 20000
|
||||||
|
assert float(retrieved.cost) == 15.50
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_usage_stats_unique_violation(self, tmp_path):
|
||||||
|
"""Test that duplicate (api_key_id, date, model) raises error."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_unique.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Create first record
|
||||||
|
stats1 = UsageStats(
|
||||||
|
api_key_id=1,
|
||||||
|
date=today,
|
||||||
|
model="gpt-4",
|
||||||
|
requests_count=10
|
||||||
|
)
|
||||||
|
session.add(stats1)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Try to create duplicate
|
||||||
|
stats2 = UsageStats(
|
||||||
|
api_key_id=1,
|
||||||
|
date=today,
|
||||||
|
model="gpt-4",
|
||||||
|
requests_count=20
|
||||||
|
)
|
||||||
|
session.add(stats2)
|
||||||
|
|
||||||
|
# Assert - Should raise IntegrityError
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_usage_stats_numeric_precision(self, tmp_path):
|
||||||
|
"""Test that cost field stores numeric values correctly."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_numeric.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
stats = UsageStats(
|
||||||
|
api_key_id=1,
|
||||||
|
date=date.today(),
|
||||||
|
model="test-model",
|
||||||
|
cost=Decimal("123.456789")
|
||||||
|
)
|
||||||
|
session.add(stats)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
retrieved = session.query(UsageStats).first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved is not None
|
||||||
|
assert float(retrieved.cost) > 0
|
||||||
|
|
||||||
|
session.close()
|
||||||
280
tests/unit/models/test_user_model.py
Normal file
280
tests/unit/models/test_user_model.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Tests for User model (T07).
|
||||||
|
|
||||||
|
T07: Creare model User (SQLAlchemy)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Import models to register them with Base
|
||||||
|
from openrouter_monitor.models import User, ApiKey, UsageStats, ApiToken
|
||||||
|
from openrouter_monitor.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUserModelBasics:
|
||||||
|
"""Test User model basic attributes and creation."""
|
||||||
|
|
||||||
|
def test_user_model_exists(self):
|
||||||
|
"""Test that User model can be imported."""
|
||||||
|
# Assert
|
||||||
|
assert User is not None
|
||||||
|
assert hasattr(User, '__tablename__')
|
||||||
|
assert User.__tablename__ == 'users'
|
||||||
|
|
||||||
|
def test_user_has_required_fields(self):
|
||||||
|
"""Test that User model has all required fields."""
|
||||||
|
# Arrange
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert hasattr(User, 'id')
|
||||||
|
assert hasattr(User, 'email')
|
||||||
|
assert hasattr(User, 'password_hash')
|
||||||
|
assert hasattr(User, 'created_at')
|
||||||
|
assert hasattr(User, 'updated_at')
|
||||||
|
assert hasattr(User, 'is_active')
|
||||||
|
|
||||||
|
def test_user_create_with_valid_data(self, tmp_path):
|
||||||
|
"""Test creating User with valid data."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_user_data.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed_password_here"
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.flush() # Apply defaults without committing
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.email == "test@example.com"
|
||||||
|
assert user.password_hash == "hashed_password_here"
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.created_at is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_user_default_is_active_true(self, tmp_path):
|
||||||
|
"""Test that is_active defaults to True."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_active_default.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(email="test@example.com", password_hash="hash")
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.is_active is True
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_user_timestamps_auto_set(self, tmp_path):
|
||||||
|
"""Test that created_at is automatically set."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_timestamps.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
before = datetime.utcnow()
|
||||||
|
user = User(email="test@example.com", password_hash="hash")
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
after = datetime.utcnow()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.created_at is not None
|
||||||
|
assert before <= user.created_at <= after
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUserConstraints:
|
||||||
|
"""Test User model constraints and validations."""
|
||||||
|
|
||||||
|
def test_user_email_unique_constraint(self, tmp_path):
|
||||||
|
"""Test that email must be unique."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_unique.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act - Create first user
|
||||||
|
user1 = User(email="unique@example.com", password_hash="hash1")
|
||||||
|
session.add(user1)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act - Try to create second user with same email
|
||||||
|
user2 = User(email="unique@example.com", password_hash="hash2")
|
||||||
|
session.add(user2)
|
||||||
|
|
||||||
|
# Assert - Should raise IntegrityError
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_user_email_index_exists(self):
|
||||||
|
"""Test that email has an index."""
|
||||||
|
# Act - Check indexes on users table
|
||||||
|
inspector = inspect(User.__table__)
|
||||||
|
indexes = inspector.indexes
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
assert any('email' in name for name in index_names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestUserRelationships:
|
||||||
|
"""Test User model relationships."""
|
||||||
|
|
||||||
|
def test_user_has_api_keys_relationship(self):
|
||||||
|
"""Test that User has api_keys relationship."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(User, 'api_keys')
|
||||||
|
|
||||||
|
def test_user_has_api_tokens_relationship(self):
|
||||||
|
"""Test that User has api_tokens relationship."""
|
||||||
|
# Assert
|
||||||
|
assert hasattr(User, 'api_tokens')
|
||||||
|
|
||||||
|
def test_api_keys_cascade_delete(self):
|
||||||
|
"""Test that api_keys have cascade delete."""
|
||||||
|
# Arrange
|
||||||
|
from sqlalchemy.orm import RelationshipProperty
|
||||||
|
|
||||||
|
# Act
|
||||||
|
api_keys_rel = getattr(User.api_keys, 'property', None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
if api_keys_rel:
|
||||||
|
assert 'delete' in str(api_keys_rel.cascade).lower() or 'all' in str(api_keys_rel.cascade).lower()
|
||||||
|
|
||||||
|
def test_api_tokens_cascade_delete(self):
|
||||||
|
"""Test that api_tokens have cascade delete."""
|
||||||
|
# Arrange
|
||||||
|
from sqlalchemy.orm import RelationshipProperty
|
||||||
|
|
||||||
|
# Act
|
||||||
|
api_tokens_rel = getattr(User.api_tokens, 'property', None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
if api_tokens_rel:
|
||||||
|
assert 'delete' in str(api_tokens_rel.cascade).lower() or 'all' in str(api_tokens_rel.cascade).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestUserDatabaseIntegration:
|
||||||
|
"""Integration tests for User model with database."""
|
||||||
|
|
||||||
|
def test_user_persist_and_retrieve(self, tmp_path):
|
||||||
|
"""Test persisting and retrieving user from database."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_user.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(email="persist@example.com", password_hash="hashed123")
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Retrieve
|
||||||
|
retrieved = session.query(User).filter_by(email="persist@example.com").first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.email == "persist@example.com"
|
||||||
|
assert retrieved.password_hash == "hashed123"
|
||||||
|
assert retrieved.id is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_user_email_filtering(self, tmp_path):
|
||||||
|
"""Test filtering users by email."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_filter.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Create multiple users
|
||||||
|
user1 = User(email="alice@example.com", password_hash="hash1")
|
||||||
|
user2 = User(email="bob@example.com", password_hash="hash2")
|
||||||
|
session.add_all([user1, user2])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = session.query(User).filter_by(email="alice@example.com").first()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is not None
|
||||||
|
assert result.email == "alice@example.com"
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_user_is_active_filtering(self, tmp_path):
|
||||||
|
"""Test filtering users by is_active status."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_active.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Create users
|
||||||
|
active_user = User(email="active@example.com", password_hash="hash1", is_active=True)
|
||||||
|
inactive_user = User(email="inactive@example.com", password_hash="hash2", is_active=False)
|
||||||
|
session.add_all([active_user, inactive_user])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
active_users = session.query(User).filter_by(is_active=True).all()
|
||||||
|
inactive_users = session.query(User).filter_by(is_active=False).all()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(active_users) == 1
|
||||||
|
assert len(inactive_users) == 1
|
||||||
|
assert active_users[0].email == "active@example.com"
|
||||||
|
assert inactive_users[0].email == "inactive@example.com"
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_user_update_timestamp(self, tmp_path):
|
||||||
|
"""Test that updated_at can be set and retrieved."""
|
||||||
|
# Arrange
|
||||||
|
engine = create_engine(f'sqlite:///{tmp_path}/test_update.db')
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(email="update@example.com", password_hash="hash")
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Act - Update
|
||||||
|
original_updated_at = user.updated_at
|
||||||
|
user.password_hash = "new_hash"
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
retrieved = session.query(User).filter_by(email="update@example.com").first()
|
||||||
|
assert retrieved.updated_at is not None
|
||||||
|
|
||||||
|
session.close()
|
||||||
116
tests/unit/test_configuration.py
Normal file
116
tests/unit/test_configuration.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Test for configuration setup (T04)."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Add src to path for importing
|
||||||
|
sys.path.insert(0, '/home/google/Sources/LucaSacchiNet/openrouter-watcher/src')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConfigurationSetup:
|
||||||
|
"""Test configuration files and settings."""
|
||||||
|
|
||||||
|
def test_config_py_exists(self):
|
||||||
|
"""Verify config.py file exists."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
assert os.path.isfile(config_path), f"File {config_path} does not exist"
|
||||||
|
|
||||||
|
def test_env_example_exists(self):
|
||||||
|
"""Verify .env.example file exists."""
|
||||||
|
env_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.env.example"
|
||||||
|
assert os.path.isfile(env_path), f"File {env_path} does not exist"
|
||||||
|
|
||||||
|
def test_config_py_has_settings_class(self):
|
||||||
|
"""Verify config.py contains Settings class."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'class Settings' in content, "config.py should contain Settings class"
|
||||||
|
|
||||||
|
def test_config_py_has_database_url(self):
|
||||||
|
"""Verify Settings has database_url field."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'database_url' in content.lower(), "Settings should have database_url field"
|
||||||
|
|
||||||
|
def test_config_py_has_secret_key(self):
|
||||||
|
"""Verify Settings has secret_key field."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'secret_key' in content.lower(), "Settings should have secret_key field"
|
||||||
|
|
||||||
|
def test_config_py_has_encryption_key(self):
|
||||||
|
"""Verify Settings has encryption_key field."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'encryption_key' in content.lower(), "Settings should have encryption_key field"
|
||||||
|
|
||||||
|
def test_config_py_uses_pydantic_settings(self):
|
||||||
|
"""Verify config.py uses pydantic_settings."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'BaseSettings' in content or 'pydantic_settings' in content, \
|
||||||
|
"config.py should use pydantic_settings BaseSettings"
|
||||||
|
|
||||||
|
def test_env_example_has_database_url(self):
|
||||||
|
"""Verify .env.example contains DATABASE_URL."""
|
||||||
|
env_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.env.example"
|
||||||
|
with open(env_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'DATABASE_URL' in content, ".env.example should contain DATABASE_URL"
|
||||||
|
|
||||||
|
def test_env_example_has_secret_key(self):
|
||||||
|
"""Verify .env.example contains SECRET_KEY."""
|
||||||
|
env_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.env.example"
|
||||||
|
with open(env_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'SECRET_KEY' in content, ".env.example should contain SECRET_KEY"
|
||||||
|
|
||||||
|
def test_env_example_has_encryption_key(self):
|
||||||
|
"""Verify .env.example contains ENCRYPTION_KEY."""
|
||||||
|
env_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.env.example"
|
||||||
|
with open(env_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'ENCRYPTION_KEY' in content, ".env.example should contain ENCRYPTION_KEY"
|
||||||
|
|
||||||
|
def test_config_can_be_imported(self):
|
||||||
|
"""Verify config module can be imported successfully."""
|
||||||
|
try:
|
||||||
|
from openrouter_monitor.config import Settings, get_settings
|
||||||
|
assert True
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Failed to import config module: {e}")
|
||||||
|
|
||||||
|
def test_settings_class_instantiates(self):
|
||||||
|
"""Verify Settings class can be instantiated with test values."""
|
||||||
|
try:
|
||||||
|
from openrouter_monitor.config import Settings
|
||||||
|
# Test with required fields (use snake_case field names)
|
||||||
|
settings = Settings(
|
||||||
|
secret_key="test-secret-key-min-32-chars-long",
|
||||||
|
encryption_key="test-32-byte-encryption-key!!"
|
||||||
|
)
|
||||||
|
assert settings is not None
|
||||||
|
assert hasattr(settings, 'database_url')
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Failed to instantiate Settings: {e}")
|
||||||
|
|
||||||
|
def test_settings_has_defaults(self):
|
||||||
|
"""Verify Settings has sensible defaults."""
|
||||||
|
try:
|
||||||
|
from openrouter_monitor.config import Settings
|
||||||
|
settings = Settings(
|
||||||
|
secret_key="test-secret-key-min-32-chars-long",
|
||||||
|
encryption_key="test-32-byte-encryption-key!!"
|
||||||
|
)
|
||||||
|
# Check default values
|
||||||
|
assert settings.database_url is not None
|
||||||
|
assert settings.debug is not None
|
||||||
|
assert settings.log_level is not None
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Settings missing defaults: {e}")
|
||||||
90
tests/unit/test_project_structure.py
Normal file
90
tests/unit/test_project_structure.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Test for project structure setup (T01)."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestProjectStructure:
|
||||||
|
"""Test project directory structure is correctly created."""
|
||||||
|
|
||||||
|
def test_src_directory_exists(self):
|
||||||
|
"""Verify src/openrouter_monitor/ directory exists."""
|
||||||
|
src_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor"
|
||||||
|
assert os.path.isdir(src_path), f"Directory {src_path} does not exist"
|
||||||
|
|
||||||
|
def test_src_init_file_exists(self):
|
||||||
|
"""Verify src/openrouter_monitor/__init__.py exists."""
|
||||||
|
init_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/__init__.py"
|
||||||
|
assert os.path.isfile(init_path), f"File {init_path} does not exist"
|
||||||
|
|
||||||
|
def test_main_py_exists(self):
|
||||||
|
"""Verify main.py exists in src."""
|
||||||
|
main_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/main.py"
|
||||||
|
assert os.path.isfile(main_path), f"File {main_path} does not exist"
|
||||||
|
|
||||||
|
def test_config_py_exists(self):
|
||||||
|
"""Verify config.py exists in src."""
|
||||||
|
config_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/config.py"
|
||||||
|
assert os.path.isfile(config_path), f"File {config_path} does not exist"
|
||||||
|
|
||||||
|
def test_database_py_exists(self):
|
||||||
|
"""Verify database.py exists in src."""
|
||||||
|
db_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/database.py"
|
||||||
|
assert os.path.isfile(db_path), f"File {db_path} does not exist"
|
||||||
|
|
||||||
|
def test_models_directory_exists(self):
|
||||||
|
"""Verify models/ directory exists with __init__.py."""
|
||||||
|
models_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/models"
|
||||||
|
init_path = os.path.join(models_path, "__init__.py")
|
||||||
|
assert os.path.isdir(models_path), f"Directory {models_path} does not exist"
|
||||||
|
assert os.path.isfile(init_path), f"File {init_path} does not exist"
|
||||||
|
|
||||||
|
def test_routers_directory_exists(self):
|
||||||
|
"""Verify routers/ directory exists with __init__.py."""
|
||||||
|
routers_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/routers"
|
||||||
|
init_path = os.path.join(routers_path, "__init__.py")
|
||||||
|
assert os.path.isdir(routers_path), f"Directory {routers_path} does not exist"
|
||||||
|
assert os.path.isfile(init_path), f"File {init_path} does not exist"
|
||||||
|
|
||||||
|
def test_services_directory_exists(self):
|
||||||
|
"""Verify services/ directory exists with __init__.py."""
|
||||||
|
services_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/services"
|
||||||
|
init_path = os.path.join(services_path, "__init__.py")
|
||||||
|
assert os.path.isdir(services_path), f"Directory {services_path} does not exist"
|
||||||
|
assert os.path.isfile(init_path), f"File {init_path} does not exist"
|
||||||
|
|
||||||
|
def test_utils_directory_exists(self):
|
||||||
|
"""Verify utils/ directory exists with __init__.py."""
|
||||||
|
utils_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/src/openrouter_monitor/utils"
|
||||||
|
init_path = os.path.join(utils_path, "__init__.py")
|
||||||
|
assert os.path.isdir(utils_path), f"Directory {utils_path} does not exist"
|
||||||
|
assert os.path.isfile(init_path), f"File {init_path} does not exist"
|
||||||
|
|
||||||
|
def test_tests_directory_structure(self):
|
||||||
|
"""Verify tests/ directory structure exists."""
|
||||||
|
tests_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/tests"
|
||||||
|
unit_path = os.path.join(tests_path, "unit")
|
||||||
|
integration_path = os.path.join(tests_path, "integration")
|
||||||
|
conftest_path = os.path.join(tests_path, "conftest.py")
|
||||||
|
|
||||||
|
assert os.path.isdir(tests_path), f"Directory {tests_path} does not exist"
|
||||||
|
assert os.path.isdir(unit_path), f"Directory {unit_path} does not exist"
|
||||||
|
assert os.path.isfile(os.path.join(unit_path, "__init__.py")), f"unit/__init__.py does not exist"
|
||||||
|
assert os.path.isdir(integration_path), f"Directory {integration_path} does not exist"
|
||||||
|
assert os.path.isfile(os.path.join(integration_path, "__init__.py")), f"integration/__init__.py does not exist"
|
||||||
|
assert os.path.isfile(conftest_path), f"File {conftest_path} does not exist"
|
||||||
|
|
||||||
|
def test_alembic_directory_exists(self):
|
||||||
|
"""Verify alembic/ directory exists."""
|
||||||
|
alembic_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/alembic"
|
||||||
|
assert os.path.isdir(alembic_path), f"Directory {alembic_path} does not exist"
|
||||||
|
|
||||||
|
def test_docs_directory_exists(self):
|
||||||
|
"""Verify docs/ directory exists."""
|
||||||
|
docs_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/docs"
|
||||||
|
assert os.path.isdir(docs_path), f"Directory {docs_path} does not exist"
|
||||||
|
|
||||||
|
def test_scripts_directory_exists(self):
|
||||||
|
"""Verify scripts/ directory exists."""
|
||||||
|
scripts_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/scripts"
|
||||||
|
assert os.path.isdir(scripts_path), f"Directory {scripts_path} does not exist"
|
||||||
103
tests/unit/test_pytest_config.py
Normal file
103
tests/unit/test_pytest_config.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Test for pytest configuration (T05)."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestPytestConfiguration:
|
||||||
|
"""Test pytest configuration and setup."""
|
||||||
|
|
||||||
|
def test_pytest_ini_exists(self):
|
||||||
|
"""Verify pytest.ini file exists."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
assert os.path.isfile(ini_path), f"File {ini_path} does not exist"
|
||||||
|
|
||||||
|
def test_pytest_ini_has_testpaths(self):
|
||||||
|
"""Verify pytest.ini contains testpaths configuration."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
with open(ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'testpaths' in content, "pytest.ini should contain testpaths"
|
||||||
|
|
||||||
|
def test_pytest_ini_has_python_files(self):
|
||||||
|
"""Verify pytest.ini contains python_files configuration."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
with open(ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'python_files' in content, "pytest.ini should contain python_files"
|
||||||
|
|
||||||
|
def test_pytest_ini_has_python_functions(self):
|
||||||
|
"""Verify pytest.ini contains python_functions configuration."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
with open(ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'python_functions' in content, "pytest.ini should contain python_functions"
|
||||||
|
|
||||||
|
def test_pytest_ini_has_addopts(self):
|
||||||
|
"""Verify pytest.ini contains addopts configuration."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
with open(ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'addopts' in content, "pytest.ini should contain addopts"
|
||||||
|
|
||||||
|
def test_pytest_ini_has_cov_config(self):
|
||||||
|
"""Verify pytest.ini contains coverage configuration."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
with open(ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'cov' in content.lower(), "pytest.ini should contain coverage config"
|
||||||
|
|
||||||
|
def test_conftest_py_exists(self):
|
||||||
|
"""Verify conftest.py file exists."""
|
||||||
|
conf_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/tests/conftest.py"
|
||||||
|
assert os.path.isfile(conf_path), f"File {conf_path} does not exist"
|
||||||
|
|
||||||
|
def test_conftest_py_has_pytest_plugins(self):
|
||||||
|
"""Verify conftest.py contains pytest_plugins."""
|
||||||
|
conf_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/tests/conftest.py"
|
||||||
|
with open(conf_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'pytest_plugins' in content or 'fixture' in content, \
|
||||||
|
"conftest.py should contain pytest plugins or fixtures"
|
||||||
|
|
||||||
|
def test_conftest_py_imports_pytest_asyncio(self):
|
||||||
|
"""Verify conftest.py imports pytest_asyncio."""
|
||||||
|
conf_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/tests/conftest.py"
|
||||||
|
with open(conf_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert 'pytest_asyncio' in content or 'asyncio' in content.lower(), \
|
||||||
|
"conftest.py should import pytest_asyncio"
|
||||||
|
|
||||||
|
def test_pytest_can_run_tests(self):
|
||||||
|
"""Verify pytest can actually run tests."""
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
['python3', '-m', 'pytest', '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, "pytest should be runnable"
|
||||||
|
assert 'pytest' in result.stdout.lower(), "pytest version should be displayed"
|
||||||
|
|
||||||
|
def test_pytest_can_discover_tests(self):
|
||||||
|
"""Verify pytest can discover tests in the project."""
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
['python3', '-m', 'pytest', '--collect-only', '-q'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd='/home/google/Sources/LucaSacchiNet/openrouter-watcher'
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, "pytest should collect tests without errors"
|
||||||
|
# Should find at least some tests
|
||||||
|
assert 'test' in result.stdout.lower(), "pytest should find tests"
|
||||||
|
|
||||||
|
def test_coverage_is_configured(self):
|
||||||
|
"""Verify coverage is configured in pytest."""
|
||||||
|
ini_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/pytest.ini"
|
||||||
|
with open(ini_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
# Check for coverage report configuration
|
||||||
|
assert any(term in content.lower() for term in ['cov-report', 'coverage', 'cov=']), \
|
||||||
|
"pytest.ini should configure coverage reporting"
|
||||||
112
tests/unit/test_requirements.py
Normal file
112
tests/unit/test_requirements.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Test for requirements.txt setup (T03)."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestRequirementsSetup:
|
||||||
|
"""Test requirements.txt contains all necessary dependencies."""
|
||||||
|
|
||||||
|
def test_requirements_txt_exists(self):
|
||||||
|
"""Verify requirements.txt file exists."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
assert os.path.isfile(req_path), f"File {req_path} does not exist"
|
||||||
|
|
||||||
|
def test_requirements_contains_fastapi(self):
|
||||||
|
"""Verify FastAPI is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'fastapi' in content, "requirements.txt should contain FastAPI"
|
||||||
|
|
||||||
|
def test_requirements_contains_uvicorn(self):
|
||||||
|
"""Verify uvicorn is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'uvicorn' in content, "requirements.txt should contain uvicorn"
|
||||||
|
|
||||||
|
def test_requirements_contains_sqlalchemy(self):
|
||||||
|
"""Verify SQLAlchemy is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'sqlalchemy' in content, "requirements.txt should contain SQLAlchemy"
|
||||||
|
|
||||||
|
def test_requirements_contains_alembic(self):
|
||||||
|
"""Verify Alembic is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'alembic' in content, "requirements.txt should contain Alembic"
|
||||||
|
|
||||||
|
def test_requirements_contains_pydantic(self):
|
||||||
|
"""Verify Pydantic is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'pydantic' in content, "requirements.txt should contain Pydantic"
|
||||||
|
|
||||||
|
def test_requirements_contains_pydantic_settings(self):
|
||||||
|
"""Verify pydantic-settings is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'pydantic-settings' in content, "requirements.txt should contain pydantic-settings"
|
||||||
|
|
||||||
|
def test_requirements_contains_python_jose(self):
|
||||||
|
"""Verify python-jose is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'python-jose' in content, "requirements.txt should contain python-jose"
|
||||||
|
|
||||||
|
def test_requirements_contains_passlib(self):
|
||||||
|
"""Verify passlib is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'passlib' in content, "requirements.txt should contain passlib"
|
||||||
|
|
||||||
|
def test_requirements_contains_cryptography(self):
|
||||||
|
"""Verify cryptography is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'cryptography' in content, "requirements.txt should contain cryptography"
|
||||||
|
|
||||||
|
def test_requirements_contains_httpx(self):
|
||||||
|
"""Verify httpx is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'httpx' in content, "requirements.txt should contain httpx"
|
||||||
|
|
||||||
|
def test_requirements_contains_pytest(self):
|
||||||
|
"""Verify pytest is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'pytest' in content, "requirements.txt should contain pytest"
|
||||||
|
|
||||||
|
def test_requirements_contains_pytest_asyncio(self):
|
||||||
|
"""Verify pytest-asyncio is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'pytest-asyncio' in content, "requirements.txt should contain pytest-asyncio"
|
||||||
|
|
||||||
|
def test_requirements_contains_pytest_cov(self):
|
||||||
|
"""Verify pytest-cov is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'pytest-cov' in content or 'pytest-coverage' in content, \
|
||||||
|
"requirements.txt should contain pytest-cov"
|
||||||
|
|
||||||
|
def test_requirements_contains_python_multipart(self):
|
||||||
|
"""Verify python-multipart is in requirements."""
|
||||||
|
req_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/requirements.txt"
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
content = f.read().lower()
|
||||||
|
assert 'python-multipart' in content, "requirements.txt should contain python-multipart"
|
||||||
50
tests/unit/test_virtual_env_setup.py
Normal file
50
tests/unit/test_virtual_env_setup.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Test for virtual environment and gitignore setup (T02)."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestVirtualEnvironmentSetup:
|
||||||
|
"""Test virtual environment and gitignore configuration."""
|
||||||
|
|
||||||
|
def test_gitignore_exists(self):
|
||||||
|
"""Verify .gitignore file exists."""
|
||||||
|
gitignore_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.gitignore"
|
||||||
|
assert os.path.isfile(gitignore_path), f"File {gitignore_path} does not exist"
|
||||||
|
|
||||||
|
def test_gitignore_contains_venv(self):
|
||||||
|
"""Verify .gitignore excludes virtual environments."""
|
||||||
|
gitignore_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.gitignore"
|
||||||
|
with open(gitignore_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert '.venv/' in content or 'venv/' in content or 'ENV/' in content, \
|
||||||
|
".gitignore should exclude virtual environment directories"
|
||||||
|
|
||||||
|
def test_gitignore_contains_pycache(self):
|
||||||
|
"""Verify .gitignore excludes __pycache__."""
|
||||||
|
gitignore_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.gitignore"
|
||||||
|
with open(gitignore_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert '__pycache__/' in content, ".gitignore should exclude __pycache__"
|
||||||
|
|
||||||
|
def test_gitignore_contains_env(self):
|
||||||
|
"""Verify .gitignore excludes .env files."""
|
||||||
|
gitignore_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.gitignore"
|
||||||
|
with open(gitignore_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert '.env' in content, ".gitignore should exclude .env files"
|
||||||
|
|
||||||
|
def test_gitignore_contains_db_files(self):
|
||||||
|
"""Verify .gitignore excludes database files."""
|
||||||
|
gitignore_path = "/home/google/Sources/LucaSacchiNet/openrouter-watcher/.gitignore"
|
||||||
|
with open(gitignore_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
assert '*.db' in content or '*.sqlite' in content or '*.sqlite3' in content, \
|
||||||
|
".gitignore should exclude database files"
|
||||||
|
|
||||||
|
def test_python_version_is_311_or_higher(self):
|
||||||
|
"""Verify Python version is 3.11 or higher."""
|
||||||
|
import sys
|
||||||
|
version_info = sys.version_info
|
||||||
|
assert version_info.major == 3 and version_info.minor >= 11, \
|
||||||
|
f"Python version should be 3.11+, found {version_info.major}.{version_info.minor}"
|
||||||
Reference in New Issue
Block a user