Feat: Enforce PostgreSQL for integration tests; add Docker test stack
- conftest.py: pytest_configure guard rejects non-postgresql+asyncpg:// URLs before any test collects (per constitution §2.5/§5.2 v1.3.0) - docker-compose.test.yml: isolated postgres-test (5433) + minio-test (9002) + api-test runner; one command runs the full suite against real PostgreSQL - Makefile: test-unit and test-integration targets - .env.test.example: documents variables needed to run tests outside Docker - Fix pre-existing test bug: integration tests using client fixture (NoOpAuthProvider) for write operations (upload/delete/patch) now use authed_client with Bearer token — these were never caught because tests never ran against a live stack Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
236
specs/008-postgres-integration-tests/plan.md
Normal file
236
specs/008-postgres-integration-tests/plan.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Implementation Plan: PostgreSQL Integration Test Infrastructure
|
||||
|
||||
**Branch**: `master` | **Date**: 2026-05-06 | **Spec**: specs/008-postgres-integration-tests/spec.md
|
||||
**Input**: Feature specification from `specs/008-postgres-integration-tests/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Enforce the constitution's PostgreSQL mandate (§2.5, §5.2 v1.3.0) for integration tests. Three concrete deliverables: (1) a fast-fail guard in `conftest.py` that rejects non-PostgreSQL URLs before any test collects, (2) a `docker-compose.test.yml` that provides isolated `postgres-test` and `minio-test` services and an `api-test` runner, and (3) a `Makefile` + `.env.test.example` that document the canonical test commands.
|
||||
|
||||
---
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12, Docker Compose v2
|
||||
**Primary Dependencies**: pytest, pytest-asyncio, asyncpg, SQLAlchemy 2.x (all already in `pyproject.toml [dev]`)
|
||||
**Storage**: PostgreSQL 16-alpine (test instance), MinIO (test instance)
|
||||
**Testing**: pytest — this feature *is* the test infrastructure change
|
||||
**Target Platform**: Developer workstation (Linux/macOS) with Docker
|
||||
**Project Type**: Infrastructure / developer-experience
|
||||
**Performance Goals**: Guard exits in < 2 s; full integration suite continues to run in < 60 s
|
||||
**Constraints**: Must not break the existing dev compose stack; no changes to application source code
|
||||
**Scale/Scope**: One guard, one compose file, one Makefile, one env example
|
||||
|
||||
---
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.5 Database abstraction — no alternative DB in integration tests | ✅ ENFORCED | This feature implements the enforcement |
|
||||
| §5.1 TDD — failing test before implementation | ✅ | Guard itself is tested by running with a bad URL before adding the guard |
|
||||
| §5.2 Test pyramid — integration tests use real PostgreSQL | ✅ ENFORCED | docker-compose.test.yml provides the real instance |
|
||||
| §5.4 CI must pass before task is done | ✅ | Verified by running the full suite via compose |
|
||||
| §6 Tech stack — asyncpg driver, Docker Compose | ✅ | No new technologies introduced |
|
||||
| §7.1 One-command local start | ✅ | `docker compose -f docker-compose.test.yml run --rm api-test` |
|
||||
| §7.2 Environment config via env vars | ✅ | .env.test.example documents all vars |
|
||||
| §7.3 Linting not optional | ✅ | ruff will run as part of task validation |
|
||||
|
||||
No violations.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/008-postgres-integration-tests/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← decisions made above
|
||||
├── spec.md ← feature specification
|
||||
└── tasks.md ← generated by /speckit-tasks
|
||||
```
|
||||
|
||||
### Source changes
|
||||
|
||||
```text
|
||||
# New files
|
||||
docker-compose.test.yml ← isolated test services + api-test runner
|
||||
.env.test.example ← documents test environment variables
|
||||
Makefile ← test-unit / test-integration targets
|
||||
|
||||
# Modified files
|
||||
api/tests/integration/conftest.py ← add postgresql+asyncpg:// dialect guard
|
||||
```
|
||||
|
||||
No application source files (`api/app/`) are modified. No UI files are touched.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### 1. conftest.py — dialect guard
|
||||
|
||||
Add a module-level `pytest_configure` hook at the top of `api/tests/integration/conftest.py`. It resolves the database URL (same logic as the `engine` fixture: prefer `TEST_DATABASE_URL`, fall back to `settings.database_url`) and calls `pytest.exit()` if the scheme is not `postgresql+asyncpg`:
|
||||
|
||||
```python
|
||||
def pytest_configure(config):
|
||||
import os
|
||||
db_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||
if not db_url.startswith("postgresql+asyncpg://"):
|
||||
pytest.exit(
|
||||
"Integration tests require a PostgreSQL database "
|
||||
"(postgresql+asyncpg://...). "
|
||||
"Set TEST_DATABASE_URL or DATABASE_URL accordingly. "
|
||||
f"Got: {db_url!r}",
|
||||
returncode=1,
|
||||
)
|
||||
```
|
||||
|
||||
The hook runs before any fixture or collection, giving an immediate, unambiguous error.
|
||||
|
||||
**Note**: This guard goes in `api/tests/integration/conftest.py` only, not in `api/tests/conftest.py`, so that unit tests (which use no database) are unaffected.
|
||||
|
||||
### 2. docker-compose.test.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: reactbin
|
||||
POSTGRES_PASSWORD: reactbin
|
||||
POSTGRES_DB: reactbin_test
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reactbin"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-test:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9002:9000"
|
||||
- "9003:9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-init-test:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio-test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio-test:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/reactbin-test
|
||||
"
|
||||
|
||||
api-test:
|
||||
build:
|
||||
context: ./api
|
||||
environment:
|
||||
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||
S3_ENDPOINT_URL: http://minio-test:9000
|
||||
S3_BUCKET_NAME: reactbin-test
|
||||
S3_ACCESS_KEY_ID: minioadmin
|
||||
S3_SECRET_ACCESS_KEY: minioadmin
|
||||
S3_REGION: us-east-1
|
||||
JWT_SECRET_KEY: test-secret-key-for-testing-only
|
||||
OWNER_USERNAME: testowner
|
||||
OWNER_PASSWORD: testpassword
|
||||
API_BASE_URL: http://localhost:8000
|
||||
MAX_UPLOAD_BYTES: "52428800"
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
minio-init-test:
|
||||
condition: service_completed_successfully
|
||||
command: ["python", "-m", "pytest", "tests/", "-v"]
|
||||
working_dir: /app
|
||||
```
|
||||
|
||||
### 3. .env.test.example
|
||||
|
||||
Documents the variables needed to run integration tests from the host (with postgres-test and minio-test already running via compose):
|
||||
|
||||
```bash
|
||||
# Integration test environment — used when running pytest directly on the host
|
||||
# Start test services first: docker compose -f docker-compose.test.yml up -d postgres-test minio-test minio-init-test
|
||||
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||
DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||
S3_ENDPOINT_URL=http://localhost:9002
|
||||
S3_BUCKET_NAME=reactbin-test
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_REGION=us-east-1
|
||||
JWT_SECRET_KEY=test-secret-key-for-testing-only
|
||||
OWNER_USERNAME=testowner
|
||||
OWNER_PASSWORD=testpassword
|
||||
API_BASE_URL=http://localhost:8000
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
```
|
||||
|
||||
### 4. Makefile
|
||||
|
||||
```makefile
|
||||
.PHONY: test-unit test-integration
|
||||
|
||||
test-unit:
|
||||
cd api && python -m pytest tests/unit/ -v
|
||||
|
||||
test-integration:
|
||||
docker compose -f docker-compose.test.yml run --rm api-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase Breakdown
|
||||
|
||||
### Phase 1: Guard (FR-001) — US1
|
||||
|
||||
- Write a failing test: run `pytest api/tests/integration/` with `TEST_DATABASE_URL=sqlite+aiosqlite:///test.db` — confirm it does NOT exit early (test that the guard is absent)
|
||||
- Add `pytest_configure` guard to `api/tests/integration/conftest.py`
|
||||
- Verify: running with SQLite URL now exits immediately with the correct message
|
||||
- Verify: running with a PostgreSQL URL proceeds normally
|
||||
|
||||
### Phase 2: Docker Compose test stack (FR-002, FR-003) — US2
|
||||
|
||||
- Write `docker-compose.test.yml` with `postgres-test`, `minio-test`, `minio-init-test`, `api-test`
|
||||
- Run `docker compose -f docker-compose.test.yml run --rm api-test` — all tests pass
|
||||
- Confirm dev stack (port 5432, 9000) is unaffected
|
||||
|
||||
### Phase 3: Documentation (FR-004, FR-005) — US3
|
||||
|
||||
- Write `.env.test.example`
|
||||
- Write `Makefile` with `test-unit` and `test-integration`
|
||||
- Verify `make test-unit` runs unit tests without Docker
|
||||
- Verify `make test-integration` invokes the compose command
|
||||
|
||||
### Phase 4: Polish
|
||||
|
||||
- `ruff check api/app/ api/tests/` — zero violations
|
||||
- `ng lint` is unaffected (no UI changes)
|
||||
|
||||
---
|
||||
|
||||
## No data model or API contracts
|
||||
|
||||
This feature touches only developer tooling. No new API endpoints, database schema changes, or UI components.
|
||||
Reference in New Issue
Block a user