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:
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user