chore: archive add-graphql-member-api proposal and create capability specs

Archive completed GraphQL API implementation proposal:
- Move proposal to openspec/changes/archive/2025-11-20-add-graphql-member-api/
- Create capability specs in openspec/specs/:
  - database-layer: SQLAlchemy async with Alembic migrations
  - graphql-api: Strawberry GraphQL with FastAPI integration
  - member-crud: Member management with conditional validation
  - project-setup: Python 3.11+ with uv package manager

All 73 tasks completed and validated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 13:26:43 +01:00
co-authored by Claude
parent 2688616ec6
commit 5bb27bdadf
11 changed files with 980 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
# database-layer Specification
## Purpose
TBD - created by archiving change add-graphql-member-api. Update Purpose after archive.
## Requirements
### Requirement: SQLAlchemy Member model with async support
The system MUST define a Member SQLAlchemy model with async session support for persisting member data with name, address, and contact information.
**Model fields:**
- `id`: Integer primary key (auto-increment)
- `first_name`: String (max 100 chars, required)
- `last_name`: String (max 100 chars, optional)
- `street`: String (max 200 chars, optional)
- `apartment_number`: String (max 20 chars, optional)
- `zip`: String (max 20 chars, optional)
- `city`: String (max 100 chars, optional)
- `country`: String (max 100 chars, optional)
- `email`: String (max 255 chars, optional)
- `phone`: String (max 50 chars, optional)
- `created_at`: DateTime (auto-set on creation)
- `updated_at`: DateTime (auto-update on modification)
#### Scenario: Member model is defined with proper constraints
**Given** src/models/member.py exists
**When** the Member class is inspected
**Then** it inherits from SQLAlchemy Base
**And** tablename is "members"
**And** first_name has nullable=False
**And** all other data fields (last_name, email, phone, address fields) have nullable=True
**And** string fields have length constraints via String(N)
#### Scenario: Timestamps are automatically managed
**Given** a new Member instance is created
**When** the instance is added to session and committed
**Then** created_at is set to current UTC timestamp
**And** updated_at is set to current UTC timestamp
**When** the instance is later modified and committed
**Then** updated_at is updated to new UTC timestamp
**And** created_at remains unchanged
#### Scenario: Model supports async operations
**Given** Member model is defined
**When** async database session is used
**Then** CRUD operations execute without blocking event loop
**And** SQLAlchemy async patterns are followed (select, add, commit)
### Requirement: Database connection and session management
The system MUST provide async database connection factory and session management with proper lifecycle handling.
**Connection configuration:**
- Async engine using aiosqlite for SQLite
- Connection pooling disabled for SQLite (single-writer)
- Echo mode configurable via DEBUG setting
#### Scenario: Async engine is created on application startup
**Given** src/database.py defines engine initialization
**When** FastAPI app starts
**Then** async engine is created with database_url from config
**And** engine is configured for SQLite with aiosqlite driver
**And** SQL echo is enabled if DEBUG=True
#### Scenario: Session factory provides isolated sessions
**Given** async_session_maker is defined
**When** resolver requests database session
**Then** new AsyncSession is created from factory
**And** session is isolated from other concurrent requests
**And** session is properly closed after request completes
#### Scenario: Session lifecycle is managed via dependency injection
**Given** get_db_session() dependency is defined
**When** FastAPI resolver depends on db_session
**Then** session is yielded for resolver use
**And** session is committed if no exceptions occur
**And** session is rolled back if exceptions occur
**And** session is closed in finally block
### Requirement: Alembic database migration setup
The system MUST use Alembic for version-controlled schema migrations with async support for SQLite database.
**Alembic configuration:**
- Migrations stored in `migrations/versions/`
- Environment configured for async operations
- Migration template includes docstring and revision metadata
#### Scenario: Alembic is initialized with project structure
**Given** alembic init migrations was run
**When** migrations/ directory is inspected
**Then** migrations/env.py exists with async configuration
**And** migrations/versions/ directory exists for migration files
**And** alembic.ini contains database connection template
#### Scenario: Initial migration creates members table
**Given** Alembic is configured
**When** developer runs `alembic revision --autogenerate -m "create members table"`
**Then** new migration file is generated in migrations/versions/
**And** upgrade() function contains CREATE TABLE for members
**And** downgrade() function contains DROP TABLE for members
**And** all Member model columns are included
#### Scenario: Migrations are applied to database
**Given** migration files exist in migrations/versions/
**When** developer runs `alembic upgrade head`
**Then** all pending migrations are executed in order
**And** members table is created in database
**And** alembic_version table tracks current revision
**And** command exits with success code
#### Scenario: Migrations are reversible
**Given** database is at current migration head
**When** developer runs `alembic downgrade -1`
**Then** most recent migration is reversed
**And** members table is dropped (for initial migration)
**And** alembic_version is updated to previous revision
### Requirement: Database initialization on application startup
The system MUST verify database connectivity and schema readiness when FastAPI application starts.
#### Scenario: Application startup checks database connection
**Given** FastAPI app has startup event handler
**When** application starts
**Then** database engine connection is tested
**And** exception is raised if database is unreachable
**And** startup log message confirms database ready
#### Scenario: Database file is created if missing
**Given** SQLite database file does not exist
**When** application starts
**Then** database file is created automatically
**And** schema tables exist after migrations run
**And** application continues startup normally
### Requirement: Test database fixtures with isolation
The system MUST provide pytest fixtures for database testing with transaction rollback to ensure test isolation.
#### Scenario: Test database session fixture is available
**Given** tests/conftest.py defines db_session fixture
**When** test function requests db_session parameter
**Then** isolated AsyncSession is provided
**And** session uses in-memory SQLite database (`:memory:`)
**And** schema is created before test runs
**And** all changes are rolled back after test completes
#### Scenario: Test database is independent from development database
**Given** tests use db_session fixture
**When** tests create or modify data
**Then** changes are not visible in development database
**And** test data does not persist between test runs
**And** tests can run in parallel without interference
### Requirement: Database query helpers for common operations
The system MUST provide reusable query patterns for fetching, creating, updating, and deleting members.
#### Scenario: Get member by ID query
**Given** member exists in database with id=1
**When** query executes `select(Member).where(Member.id == 1)`
**Then** Member instance is returned
**When** query executes with non-existent id
**Then** None is returned (not exception)
#### Scenario: List all members query
**Given** multiple members exist in database
**When** query executes `select(Member).order_by(Member.last_name.nulls_last(), Member.first_name)`
**Then** all members are returned as list
**And** members are sorted by last name (nulls last), then first name
**And** empty list is returned if no members exist
#### Scenario: Update member fields
**Given** member exists with id=1
**When** member attributes are modified and session commits
**Then** database record is updated
**And** updated_at timestamp is refreshed
**And** created_at timestamp is unchanged
#### Scenario: Delete member from database
**Given** member exists with id=1
**When** session executes delete(member) and commits
**Then** member is removed from database
**And** subsequent queries for that id return None
+305
View File
@@ -0,0 +1,305 @@
# graphql-api Specification
## Purpose
TBD - created by archiving change add-graphql-member-api. Update Purpose after archive.
## Requirements
### Requirement: Strawberry GraphQL schema with Member type
The system MUST define a GraphQL schema using Strawberry with Member object type mapping to database model fields.
**Member GraphQL type fields:**
- `id`: ID! (non-null unique identifier)
- `firstName`: String! (non-null)
- `lastName`: String (nullable)
- `street`: String (nullable)
- `apartmentNumber`: String (nullable)
- `zip`: String (nullable)
- `city`: String (nullable)
- `country`: String (nullable)
- `email`: String (nullable)
- `phone`: String (nullable)
- `createdAt`: DateTime! (non-null timestamp)
- `updatedAt`: DateTime! (non-null timestamp)
#### Scenario: Member type is defined with Strawberry decorator
**Given** src/schemas/member.py exists
**When** Member class is decorated with @strawberry.type
**Then** GraphQL type "Member" is registered in schema
**And** all fields use camelCase naming (GraphQL convention)
**And** field types match SQLAlchemy model types
**And** snake_case database fields map to camelCase GraphQL fields
#### Scenario: GraphQL schema is introspectable
**Given** FastAPI app with GraphQL endpoint is running
**When** client accesses /graphql endpoint
**Then** GraphiQL playground is displayed
**And** schema introspection reveals Member type
**And** all fields and their types are documented
### Requirement: Input types for mutations
The system MUST define separate input types for create and update operations with appropriate field requirements.
**CreateMemberInput fields (only firstName required):**
- `firstName`: String!
- `lastName`: String
- `street`: String
- `apartmentNumber`: String
- `zip`: String
- `city`: String
- `country`: String
- `email`: String
- `phone`: String
**UpdateMemberInput fields (id required, all others optional):**
- `id`: ID!
- `firstName`: String
- `lastName`: String
- `street`: String
- `apartmentNumber`: String
- `zip`: String
- `city`: String
- `country`: String
- `email`: String
- `phone`: String
#### Scenario: CreateMemberInput enforces required fields
**Given** CreateMemberInput type is defined
**When** client sends mutation without firstName
**Then** GraphQL validation error is returned
**And** error message indicates firstName is required
**When** client sends mutation with only firstName
**Then** input validation passes
**And** member is created with only firstName populated
#### Scenario: UpdateMemberInput allows partial updates
**Given** UpdateMemberInput type is defined
**When** client sends mutation with only id and email
**Then** only email field is updated in database
**And** all other fields remain unchanged
**When** client sends mutation without id
**Then** GraphQL validation error is returned
### Requirement: Query resolvers for reading members
The system MUST provide GraphQL query resolvers for fetching individual members and listing all members.
**Query operations:**
- `member(id: ID!): Member` - Get single member by ID
- `members: [Member!]!` - List all members
#### Scenario: Query single member by ID
**Given** member exists with id=1
**When** client executes query:
```graphql
query {
member(id: 1) {
id
firstName
lastName
email
}
}
```
**Then** response contains member data
**And** response matches GraphQL Member type structure
#### Scenario: Query member with non-existent ID
**Given** no member exists with id=999
**When** client executes query `member(id: 999)`
**Then** response returns null for member field
**And** no error is raised (null is valid for nullable return)
#### Scenario: List all members
**Given** multiple members exist in database
**When** client executes query:
```graphql
query {
members {
id
firstName
lastName
}
}
```
**Then** response contains array of all members
**And** members are sorted by last name, first name
**When** no members exist
**Then** response contains empty array
### Requirement: Mutation resolvers for modifying members
The system MUST provide GraphQL mutation resolvers for creating, updating, and deleting members.
**Mutation operations:**
- `createMember(input: CreateMemberInput!): Member!` - Create new member
- `updateMember(input: UpdateMemberInput!): Member!` - Update existing member
- `deleteMember(id: ID!): Boolean!` - Delete member
#### Scenario: Create new member mutation
**Given** valid CreateMemberInput is provided
**When** client executes mutation:
```graphql
mutation {
createMember(input: {
firstName: "Jane"
lastName: "Smith"
street: "123 Main St"
zip: "12345"
city: "Springfield"
country: "USA"
email: "jane@example.com"
phone: "+15551234567"
}) {
id
firstName
email
}
}
```
**Then** new member is persisted to database
**And** response contains newly created member with generated id
**And** createdAt and updatedAt are populated
#### Scenario: Create member with minimal data (firstName only)
**Given** CreateMemberInput with only firstName is provided
**When** client executes mutation:
```graphql
mutation {
createMember(input: {
firstName: "John"
}) {
id
firstName
lastName
email
}
}
```
**Then** new member is created in database
**And** firstName is "John"
**And** lastName, email, phone, and address fields are null
**And** response contains member with null optional fields
#### Scenario: Create member with invalid input
**Given** CreateMemberInput has invalid email format
**When** client executes createMember mutation
**Then** GraphQL error is returned
**And** error message indicates validation failure
**And** no database record is created
#### Scenario: Update existing member mutation
**Given** member exists with id=1
**When** client executes mutation:
```graphql
mutation {
updateMember(input: {
id: 1
email: "newemail@example.com"
phone: "+15559876543"
}) {
id
email
phone
updatedAt
}
}
```
**Then** member email and phone are updated in database
**And** updatedAt timestamp is refreshed
**And** all other fields remain unchanged
#### Scenario: Update non-existent member
**Given** no member exists with id=999
**When** client executes updateMember with id=999
**Then** GraphQL error is returned
**And** error message indicates "Member not found"
#### Scenario: Delete member mutation
**Given** member exists with id=1
**When** client executes mutation:
```graphql
mutation {
deleteMember(id: 1)
}
```
**Then** member is removed from database
**And** response returns true
**When** query attempts to fetch deleted member
**Then** response returns null
#### Scenario: Delete non-existent member
**Given** no member exists with id=999
**When** client executes deleteMember(id: 999)
**Then** GraphQL error is returned
**And** error message indicates "Member not found"
### Requirement: FastAPI integration with GraphQL endpoint
The system MUST integrate Strawberry GraphQL schema with FastAPI application at /graphql path with GraphiQL playground enabled.
#### Scenario: GraphQL endpoint is mounted on FastAPI app
**Given** src/main.py creates FastAPI app
**When** Strawberry schema is created from Query and Mutation classes
**Then** GraphQLRouter is created with schema
**And** router is mounted at /graphql path
**And** GraphiQL is enabled for development
#### Scenario: GraphQL playground is accessible
**Given** FastAPI app is running
**When** browser navigates to http://localhost:8000/graphql
**Then** GraphiQL interface is displayed
**And** schema documentation is available
**And** queries can be executed interactively
#### Scenario: GraphQL endpoint accepts POST requests
**Given** GraphQL endpoint is configured
**When** client sends POST to /graphql with query in body
**Then** query is executed against schema
**And** JSON response is returned with data or errors
### Requirement: Error handling with meaningful messages
The system MUST provide clear, actionable error messages for validation failures, not found errors, and server errors.
#### Scenario: Input validation error provides field-level details
**Given** createMember mutation receives invalid email
**When** mutation executes
**Then** GraphQL error includes message "Invalid email format"
**And** error path indicates which input field failed
**And** HTTP status code is 400 (Bad Request)
#### Scenario: Not found error provides resource context
**Given** member query requests non-existent id=999
**When** query executes
**Then** error message is "Member with ID 999 not found"
**And** error type indicates resource not found
#### Scenario: Database error is handled gracefully
**Given** database connection fails during query
**When** query executes
**Then** GraphQL error indicates server error
**And** internal error details are logged
**And** client receives generic "Internal server error" message
**And** HTTP status code is 500
+280
View File
@@ -0,0 +1,280 @@
# member-crud Specification
## Purpose
TBD - created by archiving change add-graphql-member-api. Update Purpose after archive.
## Requirements
### Requirement: Create member with validation
The system MUST allow creating new members with only firstName required, optionally validating email and phone format when provided.
**Validation rules:**
- firstName: Required field, must be non-empty string
- Email (when provided): Must match regex pattern `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
- Phone (when provided): Must match E.164 format `^\+?[1-9]\d{1,14}$`
- All other fields (lastName, address fields, email, phone) are optional
- Email and phone do NOT require uniqueness checking
- Format validation only applies when field is provided (not null/empty)
#### Scenario: Create member with valid data
**Given** client provides valid member data:
```python
{
"first_name": "John",
"last_name": "Doe",
"street": "123 Main Street",
"apartment_number": "Apt 4B",
"zip": "12345",
"city": "Springfield",
"country": "USA",
"email": "john.doe@example.com",
"phone": "+15551234567"
}
```
**When** createMember mutation executes
**Then** new Member record is created in database
**And** member.id is auto-generated
**And** member.created_at is set to current timestamp
**And** member.updated_at is set to current timestamp
**And** member object is returned to client
#### Scenario: Create member with invalid email format
**Given** client provides email "invalid-email"
**When** createMember mutation executes
**Then** validation error is raised
**And** error message is "Invalid email format: invalid-email"
**And** no database record is created
#### Scenario: Create member with invalid phone format
**Given** client provides phone "123-456"
**When** createMember mutation executes
**Then** validation error is raised
**And** error message is "Invalid phone format: 123-456"
**And** no database record is created
#### Scenario: Create member with duplicate email (allowed)
**Given** member exists with email "john@example.com"
**When** client creates another member with email "john@example.com"
**Then** new member is created successfully
**And** both members coexist in database
**And** no uniqueness constraint error occurs
#### Scenario: Create member without apartment number
**Given** client omits apartment_number field
**When** createMember mutation executes
**Then** member is created with apartment_number=None
**And** all other fields are populated correctly
#### Scenario: Create member with only firstName (minimal data)
**Given** client provides only first_name="Jane"
**When** createMember mutation executes
**Then** new Member record is created in database
**And** member.first_name is "Jane"
**And** member.last_name is None
**And** member.email is None
**And** member.phone is None
**And** all address fields (street, zip, city, country, apartment_number) are None
**And** created_at and updated_at are populated
#### Scenario: Create member with optional email (valid format)
**Given** client provides first_name="John" and email="john@example.com"
**When** createMember mutation executes
**Then** email format is validated
**And** member is created with validated email
**And** other optional fields are None
#### Scenario: Create member with optional email (invalid format)
**Given** client provides first_name="John" and email="invalid"
**When** createMember mutation executes
**Then** validation error is raised
**And** error message is "Invalid email format: invalid"
**And** no database record is created
### Requirement: Read member by ID
The system MUST allow fetching a single member by unique identifier with all fields populated.
#### Scenario: Get existing member by ID
**Given** member exists with id=1
**When** member(id=1) query executes
**Then** Member object is returned
**And** all fields match database record
**And** timestamps are in ISO 8601 format
#### Scenario: Get member with non-existent ID
**Given** no member exists with id=999
**When** member(id=999) query executes
**Then** None is returned
**And** no exception is raised
### Requirement: List all members with sorting
The system MUST allow fetching all members sorted alphabetically by last name (with nulls last), then first name.
#### Scenario: List members in sorted order
**Given** members exist:
- id=1: firstName="Alice", lastName="Smith"
- id=2: firstName="Bob", lastName="Jones"
- id=3: firstName="Charlie", lastName="Smith"
- id=4: firstName="Diana", lastName=None
**When** members query executes
**Then** members are returned in order: Jones, Smith (Alice), Smith (Charlie), Diana (null lastName)
**And** sorting is case-insensitive
**And** members with null lastName appear at the end
#### Scenario: List members when database is empty
**Given** no members exist in database
**When** members query executes
**Then** empty list is returned
**And** no error is raised
#### Scenario: List members returns all fields
**Given** members exist in database
**When** members query requests all fields
**Then** each member includes id, name, address, contact, timestamps
**And** no fields are null except apartmentNumber (if not provided)
### Requirement: Update member with partial field changes
The system MUST allow updating specific member fields while preserving unchanged fields and refreshing the updated_at timestamp.
#### Scenario: Update member email
**Given** member exists with id=1, email="old@example.com"
**When** updateMember mutation executes with:
```python
{"id": 1, "email": "new@example.com"}
```
**Then** member.email is updated to "new@example.com"
**And** member.updated_at is refreshed to current timestamp
**And** member.created_at remains unchanged
**And** all other fields remain unchanged
#### Scenario: Update multiple fields simultaneously
**Given** member exists with id=1
**When** updateMember mutation provides phone, street, and city
**Then** all three fields are updated
**And** other fields remain unchanged
**And** updated_at is refreshed
#### Scenario: Update member with invalid email
**Given** member exists with id=1
**When** updateMember mutation provides email="invalid"
**Then** validation error is raised
**And** database record is not modified
**And** error message indicates invalid email format
#### Scenario: Update non-existent member
**Given** no member exists with id=999
**When** updateMember(id=999) mutation executes
**Then** error is raised
**And** error message is "Member with ID 999 not found"
#### Scenario: Update with no field changes
**Given** member exists with id=1
**When** updateMember mutation provides only id (no other fields)
**Then** no fields are modified
**And** updated_at is NOT refreshed (no actual changes)
### Requirement: Delete member from system
The system MUST allow permanent deletion of member records by ID.
#### Scenario: Delete existing member
**Given** member exists with id=1
**When** deleteMember(id=1) mutation executes
**Then** member is removed from database
**And** mutation returns true
**When** subsequent query for id=1 executes
**Then** None is returned
#### Scenario: Delete non-existent member
**Given** no member exists with id=999
**When** deleteMember(id=999) mutation executes
**Then** error is raised
**And** error message is "Member with ID 999 not found"
**And** mutation returns false or raises exception
### Requirement: Seed database with sample member
The system MUST provide an idempotent script to populate database with one sample member for development and testing.
**Sample member data:**
- first_name: "Jane"
- last_name: "Doe"
- street: None (demonstrates optional address fields)
- apartment_number: None
- zip: None
- city: None
- country: None
- email: "jane.doe@example.com"
- phone: None (demonstrates optional phone)
#### Scenario: Seed script creates sample member
**Given** database schema exists (migrations applied)
**And** no member exists with email "jane.doe@example.com"
**When** seed script executes
**Then** sample member is created in database
**And** script prints "Sample member created successfully"
**And** script exits with code 0
#### Scenario: Seed script is idempotent
**Given** sample member already exists with email "jane.doe@example.com"
**When** seed script executes again
**Then** no new member is created
**And** existing member is not modified
**And** script prints "Sample member already exists"
**And** script exits with code 0
#### Scenario: Seed script verifies sample member
**Given** seed script has completed
**When** query executes for email "jane.doe@example.com"
**Then** member exists with all sample data fields populated
**And** created_at and updated_at timestamps are valid
### Requirement: Input validation with clear error messages
The system MUST validate all inputs and provide actionable error messages identifying the validation failure.
#### Scenario: Empty required field error
**Given** createMember mutation has firstName=""
**When** mutation executes
**Then** validation error is raised
**And** error message is "firstName cannot be empty"
#### Scenario: Field length exceeds maximum
**Given** createMember mutation has firstName with 150 characters
**When** mutation executes
**Then** validation error is raised
**And** error message indicates maximum length of 100 characters
#### Scenario: Multiple validation errors
**Given** createMember mutation has invalid email AND invalid phone
**When** mutation executes
**Then** validation errors for both fields are returned
**And** error messages clearly identify each invalid field
+192
View File
@@ -0,0 +1,192 @@
# project-setup Specification
## Purpose
TBD - created by archiving change add-graphql-member-api. Update Purpose after archive.
## Requirements
### Requirement: Python project structure with uv dependency management
The project MUST use Python 3.11+ with uv for fast, reliable dependency management and provide a standard src/ layout for code organization.
#### Scenario: Developer initializes new development environment
**Given** a developer has cloned the repository
**When** they run `uv sync`
**Then** all dependencies are installed in a virtual environment
**And** the environment is ready for development within 30 seconds
#### Scenario: Developer runs the application
**Given** dependencies are installed
**When** developer runs `uv run uvicorn src.main:app --reload`
**Then** the FastAPI server starts on http://localhost:8000
**And** GraphQL playground is available at http://localhost:8000/graphql
### Requirement: Project configuration in pyproject.toml
The project MUST define all metadata, dependencies, and tool configurations in pyproject.toml following modern Python packaging standards.
**Dependencies required:**
- fastapi >= 0.104.0 (Web framework)
- strawberry-graphql[fastapi] >= 0.215.0 (GraphQL integration)
- sqlalchemy[asyncio] >= 2.0.0 (ORM with async support)
- aiosqlite >= 0.19.0 (Async SQLite driver)
- alembic >= 1.12.0 (Database migrations)
- pydantic-settings >= 2.0.0 (Configuration management)
- uvicorn[standard] >= 0.24.0 (ASGI server)
**Development dependencies required:**
- pytest >= 7.4.0 (Test framework)
- pytest-asyncio >= 0.21.0 (Async test support)
- black >= 23.0.0 (Code formatting)
- ruff >= 0.1.0 (Linting)
- isort >= 5.12.0 (Import sorting)
#### Scenario: Dependencies are declared with version constraints
**Given** pyproject.toml exists
**When** developer inspects [project.dependencies]
**Then** all required packages are listed with minimum versions
**And** version constraints allow patch/minor updates
#### Scenario: Development tools are configured
**Given** pyproject.toml contains tool configurations
**When** developer runs `black .`
**Then** code is formatted with line length 88
**When** developer runs `ruff check .`
**Then** code is linted against configured rules
### Requirement: Source directory structure following layered architecture
The project MUST organize code into src/ directory with clear separation of concerns across API, business logic, and data layers.
**Required directory structure:**
```
src/
├── __init__.py
├── main.py # FastAPI application, startup/shutdown
├── config.py # Settings and configuration
├── database.py # Database connection, session factory
├── models/ # SQLAlchemy ORM models
│ ├── __init__.py
│ └── member.py
├── schemas/ # Strawberry GraphQL types
│ ├── __init__.py
│ └── member.py
└── resolvers/ # GraphQL query/mutation resolvers
├── __init__.py
└── member.py
```
#### Scenario: Code is organized by architectural layer
**Given** the src/ directory exists
**When** a developer navigates the codebase
**Then** models/ contains only SQLAlchemy ORM definitions
**And** schemas/ contains only Strawberry GraphQL type definitions
**And** resolvers/ contains only GraphQL resolver functions
**And** each module has clear, single responsibility
#### Scenario: Main application entry point is defined
**Given** src/main.py exists
**When** the file is imported
**Then** it exports a FastAPI `app` instance
**And** app includes GraphQL route at /graphql
**And** app includes startup event to verify database connection
### Requirement: Testing infrastructure with pytest
The project MUST provide pytest configuration for running async tests with database fixtures and code coverage reporting.
#### Scenario: Async tests can be executed
**Given** pytest and pytest-asyncio are installed
**When** developer runs `pytest`
**Then** all tests in tests/ directory are discovered
**And** async test functions execute correctly
**And** test results are displayed with pass/fail status
#### Scenario: Database fixtures are available for tests
**Given** tests/conftest.py defines database fixtures
**When** a test function requests `db_session` fixture
**Then** an isolated test database session is provided
**And** session is rolled back after test completion
**And** no test data persists between test runs
### Requirement: Code quality tooling configuration
The project MUST configure black, ruff, and isort for consistent code formatting and linting with settings in pyproject.toml.
**Black configuration:**
- Line length: 88 characters
- Target version: Python 3.11
- Skip string normalization: false
**Ruff configuration:**
- Line length: 88 characters
- Select: E, F, W, I (pycodestyle, pyflakes, warnings, isort)
- Ignore: E501 (line too long, handled by black)
**Isort configuration:**
- Profile: black (compatible settings)
- Multi-line output: 3 (vertical hanging indent)
#### Scenario: Code formatting is enforced
**Given** black is configured in pyproject.toml
**When** developer runs `black --check .`
**Then** all Python files are checked for formatting
**And** exit code is 0 if all files are formatted correctly
**And** exit code is 1 if any files need formatting
#### Scenario: Code quality checks pass
**Given** ruff is configured in pyproject.toml
**When** developer runs `ruff check .`
**Then** all Python files are linted
**And** no errors are reported for compliant code
**And** clear error messages are shown for violations
### Requirement: Environment configuration with .env support
The project MUST support environment-based configuration using .env files with pydantic-settings for type-safe config values.
#### Scenario: Default configuration works for development
**Given** no .env file exists
**When** application starts
**Then** it uses default SQLite database path
**And** it runs in debug mode
**And** application starts successfully
#### Scenario: Environment variables override defaults
**Given** .env file contains `DATABASE_URL=sqlite+aiosqlite:///./test.db`
**When** application loads configuration
**Then** settings.database_url equals "sqlite+aiosqlite:///./test.db"
**And** custom database path is used
#### Scenario: Configuration is type-safe
**Given** src/config.py defines Settings class
**When** invalid configuration value is provided
**Then** pydantic validation raises clear error
**And** application fails fast on startup
### Requirement: Development scripts for common tasks
The project MUST provide executable scripts for database seeding and common development tasks.
**Required scripts:**
- scripts/seed.py - Populate database with sample member
#### Scenario: Seed script creates sample data
**Given** database schema exists from migrations
**When** developer runs `uv run python scripts/seed.py`
**Then** sample member is created in database
**And** script is idempotent (safe to run multiple times)
**And** success message is displayed