From 5bb27bdadf9bb6dfe54531ef872106128176c5fe Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 20 Nov 2025 13:26:43 +0100 Subject: [PATCH] chore: archive add-graphql-member-api proposal and create capability specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../design.md | 0 .../proposal.md | 0 .../specs/database-layer/spec.md | 0 .../specs/graphql-api/spec.md | 0 .../specs/member-crud/spec.md | 0 .../specs/project-setup/spec.md | 0 .../tasks.md | 0 openspec/specs/database-layer/spec.md | 203 ++++++++++++ openspec/specs/graphql-api/spec.md | 305 ++++++++++++++++++ openspec/specs/member-crud/spec.md | 280 ++++++++++++++++ openspec/specs/project-setup/spec.md | 192 +++++++++++ 11 files changed, 980 insertions(+) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/design.md (100%) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/proposal.md (100%) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/specs/database-layer/spec.md (100%) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/specs/graphql-api/spec.md (100%) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/specs/member-crud/spec.md (100%) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/specs/project-setup/spec.md (100%) rename openspec/changes/{add-graphql-member-api => archive/2025-11-20-add-graphql-member-api}/tasks.md (100%) create mode 100644 openspec/specs/database-layer/spec.md create mode 100644 openspec/specs/graphql-api/spec.md create mode 100644 openspec/specs/member-crud/spec.md create mode 100644 openspec/specs/project-setup/spec.md diff --git a/openspec/changes/add-graphql-member-api/design.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/design.md similarity index 100% rename from openspec/changes/add-graphql-member-api/design.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/design.md diff --git a/openspec/changes/add-graphql-member-api/proposal.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/proposal.md similarity index 100% rename from openspec/changes/add-graphql-member-api/proposal.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/proposal.md diff --git a/openspec/changes/add-graphql-member-api/specs/database-layer/spec.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/database-layer/spec.md similarity index 100% rename from openspec/changes/add-graphql-member-api/specs/database-layer/spec.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/database-layer/spec.md diff --git a/openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/graphql-api/spec.md similarity index 100% rename from openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/graphql-api/spec.md diff --git a/openspec/changes/add-graphql-member-api/specs/member-crud/spec.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/member-crud/spec.md similarity index 100% rename from openspec/changes/add-graphql-member-api/specs/member-crud/spec.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/member-crud/spec.md diff --git a/openspec/changes/add-graphql-member-api/specs/project-setup/spec.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/project-setup/spec.md similarity index 100% rename from openspec/changes/add-graphql-member-api/specs/project-setup/spec.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/specs/project-setup/spec.md diff --git a/openspec/changes/add-graphql-member-api/tasks.md b/openspec/changes/archive/2025-11-20-add-graphql-member-api/tasks.md similarity index 100% rename from openspec/changes/add-graphql-member-api/tasks.md rename to openspec/changes/archive/2025-11-20-add-graphql-member-api/tasks.md diff --git a/openspec/specs/database-layer/spec.md b/openspec/specs/database-layer/spec.md new file mode 100644 index 0000000..791543d --- /dev/null +++ b/openspec/specs/database-layer/spec.md @@ -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 + diff --git a/openspec/specs/graphql-api/spec.md b/openspec/specs/graphql-api/spec.md new file mode 100644 index 0000000..3492597 --- /dev/null +++ b/openspec/specs/graphql-api/spec.md @@ -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 + diff --git a/openspec/specs/member-crud/spec.md b/openspec/specs/member-crud/spec.md new file mode 100644 index 0000000..5cae610 --- /dev/null +++ b/openspec/specs/member-crud/spec.md @@ -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 + diff --git a/openspec/specs/project-setup/spec.md b/openspec/specs/project-setup/spec.md new file mode 100644 index 0000000..a41fdca --- /dev/null +++ b/openspec/specs/project-setup/spec.md @@ -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 +