feat: Add organization member support with memberType field
Implements support for both individual persons and organizations as members. Changes: - Added MemberType enum (INDIVIDUAL, ORGANIZATION) to distinguish member types - Added member_type column to database (defaults to INDIVIDUAL for backward compatibility) - Added company_name field for organizations - Made first_name nullable (required for individuals, optional for organizations) - Implemented conditional validation: - INDIVIDUAL members require first_name - ORGANIZATION members require company_name - Updated GraphQL schema with new memberType and companyName fields - Updated all resolvers to handle new fields and validation - Added comprehensive unit tests for validation logic - Updated existing tests to work with new fields - All 53 tests passing Technical notes: - Using memberType instead of 'kind' to avoid GraphQL introspection conflicts - Using native_enum=False for SQLite compatibility - Using batch_alter_table for SQLite ALTER COLUMN compatibility - Backward compatible: existing members automatically become INDIVIDUAL type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
"""add organization member support
|
||||||
|
|
||||||
|
Revision ID: b61b6b2d83cc
|
||||||
|
Revises: 0f79356844b5
|
||||||
|
Create Date: 2025-12-04 12:03:16.548989
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b61b6b2d83cc'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '0f79356844b5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Add member_type column with default 'INDIVIDUAL'
|
||||||
|
op.add_column(
|
||||||
|
'members',
|
||||||
|
sa.Column('member_type', sa.String(length=20), nullable=False, server_default='INDIVIDUAL')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add company_name column (nullable)
|
||||||
|
op.add_column(
|
||||||
|
'members',
|
||||||
|
sa.Column('company_name', sa.String(length=200), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alter first_name to be nullable (SQLite compatible using batch operations)
|
||||||
|
with op.batch_alter_table('members', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'first_name',
|
||||||
|
existing_type=sa.String(length=100),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# Remove company_name and member_type columns
|
||||||
|
op.drop_column('members', 'company_name')
|
||||||
|
op.drop_column('members', 'member_type')
|
||||||
|
|
||||||
|
# Revert first_name to nullable=False
|
||||||
|
# Note: This may fail if there are organization members without first_name
|
||||||
|
with op.batch_alter_table('members', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'first_name',
|
||||||
|
existing_type=sa.String(length=100),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Design: Organization Member Support
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
### Member Table
|
||||||
|
- Add `member_type` column: Enum (`INDIVIDUAL`, `ORGANIZATION`). Default to `INDIVIDUAL` for existing records.
|
||||||
|
- Add `company_name` column: String, nullable.
|
||||||
|
- Alter `first_name`: Change from `nullable=False` to `nullable=True`.
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
- **Application Level**:
|
||||||
|
- If `member_type` is `INDIVIDUAL`: `first_name` must be present.
|
||||||
|
- If `member_type` is `ORGANIZATION`: `company_name` must be present.
|
||||||
|
- **Database Level**:
|
||||||
|
- We can't easily enforce conditional not-null constraints in standard SQL without triggers or check constraints. For simplicity and portability (SQLite/Postgres), we will enforce this at the application level (Pydantic/SQLAlchemy validators) and potentially add a CHECK constraint if supported by both dialects easily.
|
||||||
|
- `first_name` will become nullable in the DB schema.
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### GraphQL Schema
|
||||||
|
- Update `Member` type to include `memberType` and `companyName`.
|
||||||
|
- Update `createMember` and `updateMember` inputs.
|
||||||
|
- Add validation logic in resolvers to enforce the conditional requirements.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
1. Add `member_type` column with default 'INDIVIDUAL'.
|
||||||
|
2. Add `company_name` column.
|
||||||
|
3. Alter `first_name` to be nullable.
|
||||||
|
|
||||||
|
## Technical Note
|
||||||
|
- Using `memberType` instead of `kind` to avoid conflicts with GraphQL introspection queries which use `kind` as a metadata field.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Proposal: Support Organization Members
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Update the member data model to support both individual persons and organizations. Organizations will have a company name instead of a mandatory first name.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
Currently, the system only supports individual members with a mandatory first name. We need to accommodate organizational members (companies, associations, etc.) where the primary identifier is the organization name.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Allow distinguishing between individual and organization members.
|
||||||
|
- Make `first_name` optional for organizations.
|
||||||
|
- Add `company_name` field, mandatory for organizations.
|
||||||
|
- Ensure data integrity based on member type.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- Complex hierarchy of organizations and contacts (for now, just a simple distinction).
|
||||||
|
|
||||||
|
## Technical Note
|
||||||
|
- Field name: `memberType` (not `kind` to avoid GraphQL introspection conflicts)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Spec: Organization Support
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Distinguish member kind
|
||||||
|
|
||||||
|
The system MUST allow defining a member as either an individual or an organization.
|
||||||
|
|
||||||
|
#### Scenario: Create individual member
|
||||||
|
**Given** client provides kind="INDIVIDUAL"
|
||||||
|
**And** first_name="John"
|
||||||
|
**When** createMember mutation executes
|
||||||
|
**Then** member is created with kind INDIVIDUAL
|
||||||
|
**And** first_name is "John"
|
||||||
|
|
||||||
|
#### Scenario: Create organization member
|
||||||
|
**Given** client provides kind="ORGANIZATION"
|
||||||
|
**And** company_name="Acme Corp"
|
||||||
|
**When** createMember mutation executes
|
||||||
|
**Then** member is created with kind ORGANIZATION
|
||||||
|
**And** company_name is "Acme Corp"
|
||||||
|
|
||||||
|
### Requirement: Conditional mandatory fields
|
||||||
|
|
||||||
|
The system MUST enforce different mandatory fields based on member kind.
|
||||||
|
|
||||||
|
#### Scenario: Organization requires company name
|
||||||
|
**Given** client provides kind="ORGANIZATION"
|
||||||
|
**And** company_name is missing or empty
|
||||||
|
**When** createMember mutation executes
|
||||||
|
**Then** validation error is raised
|
||||||
|
**And** error message indicates company_name is required for organizations
|
||||||
|
|
||||||
|
#### Scenario: Organization allows missing first name
|
||||||
|
**Given** client provides kind="ORGANIZATION"
|
||||||
|
**And** company_name="Acme Corp"
|
||||||
|
**And** first_name is missing
|
||||||
|
**When** createMember mutation executes
|
||||||
|
**Then** member is created successfully
|
||||||
|
**And** first_name is null
|
||||||
|
|
||||||
|
#### Scenario: Individual requires first name
|
||||||
|
**Given** client provides kind="INDIVIDUAL"
|
||||||
|
**And** first_name is missing
|
||||||
|
**When** createMember mutation executes
|
||||||
|
**Then** validation error is raised
|
||||||
|
**And** error message indicates first_name is required for individuals
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Member data structure
|
||||||
|
|
||||||
|
The Member data structure MUST be modified to include kind and company_name, and allow nullable first_name.
|
||||||
|
|
||||||
|
#### Scenario: Query member includes new fields
|
||||||
|
**Given** member exists with kind="ORGANIZATION" and company_name="Acme Corp"
|
||||||
|
**When** member query executes requesting kind and companyName
|
||||||
|
**Then** kind is returned as "ORGANIZATION"
|
||||||
|
**And** companyName is returned as "Acme Corp"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Tasks: Support Organization Members
|
||||||
|
|
||||||
|
- [x] Create database migration to add `member_type` and `company_name` and make `first_name` nullable <!-- id: 0 -->
|
||||||
|
- [x] Update SQLAlchemy `Member` model with new fields and validation logic <!-- id: 1 -->
|
||||||
|
- [x] Update Pydantic schemas / GraphQL inputs to support new fields (`memberType`, `companyName`) <!-- id: 2 -->
|
||||||
|
- [x] Update `createMember` and `updateMember` resolvers with conditional validation <!-- id: 3 -->
|
||||||
|
- [x] Update tests to cover new scenarios (organization creation, validation rules) <!-- id: 4 -->
|
||||||
|
- [x] Verify backward compatibility for existing individual members <!-- id: 5 -->
|
||||||
|
|
||||||
|
**Note**: Using `memberType` field name (not `kind`) to avoid GraphQL introspection conflicts.
|
||||||
+26
-4
@@ -1,12 +1,19 @@
|
|||||||
|
import enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import String, func
|
from sqlalchemy import Enum, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from src.database import Base
|
from src.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MemberType(str, enum.Enum):
|
||||||
|
"""Member type enumeration."""
|
||||||
|
INDIVIDUAL = "INDIVIDUAL"
|
||||||
|
ORGANIZATION = "ORGANIZATION"
|
||||||
|
|
||||||
|
|
||||||
class Member(Base):
|
class Member(Base):
|
||||||
"""Member model for club/society management."""
|
"""Member model for club/society management."""
|
||||||
|
|
||||||
@@ -15,8 +22,19 @@ class Member(Base):
|
|||||||
# Primary key
|
# Primary key
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
# Required field
|
# Member type (default to INDIVIDUAL for backward compatibility)
|
||||||
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
member_type: Mapped[MemberType] = mapped_column(
|
||||||
|
Enum(MemberType, native_enum=False, length=20),
|
||||||
|
nullable=False,
|
||||||
|
default=MemberType.INDIVIDUAL,
|
||||||
|
server_default="INDIVIDUAL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# First name (required for individuals, optional for organizations)
|
||||||
|
first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Organization name (required for organizations)
|
||||||
|
company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
# Optional fields
|
# Optional fields
|
||||||
last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
@@ -40,4 +58,8 @@ class Member(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Member(id={self.id}, first_name='{self.first_name}', last_name='{self.last_name}')>"
|
if self.member_type == MemberType.ORGANIZATION:
|
||||||
|
name = self.company_name
|
||||||
|
else:
|
||||||
|
name = f"{self.first_name} {self.last_name or ''}".strip()
|
||||||
|
return f"<Member(id={self.id}, type={self.member_type.value}, name='{name}')>"
|
||||||
|
|||||||
+34
-6
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
|||||||
from src.database import async_session_maker
|
from src.database import async_session_maker
|
||||||
from src.models.member import Member as MemberModel
|
from src.models.member import Member as MemberModel
|
||||||
from src.schemas.member import CreateMemberInput, Member, UpdateMemberInput
|
from src.schemas.member import CreateMemberInput, Member, UpdateMemberInput
|
||||||
from src.validation import validate_email, validate_first_name, validate_phone
|
from src.validation import validate_email, validate_phone, validate_member_type_requirements
|
||||||
|
|
||||||
|
|
||||||
class MemberNotFoundError(Exception):
|
class MemberNotFoundError(Exception):
|
||||||
@@ -31,7 +31,9 @@ class Query:
|
|||||||
|
|
||||||
return Member(
|
return Member(
|
||||||
id=db_member.id,
|
id=db_member.id,
|
||||||
|
member_type=db_member.member_type,
|
||||||
first_name=db_member.first_name,
|
first_name=db_member.first_name,
|
||||||
|
company_name=db_member.company_name,
|
||||||
last_name=db_member.last_name,
|
last_name=db_member.last_name,
|
||||||
street=db_member.street,
|
street=db_member.street,
|
||||||
apartment_number=db_member.apartment_number,
|
apartment_number=db_member.apartment_number,
|
||||||
@@ -58,7 +60,9 @@ class Query:
|
|||||||
return [
|
return [
|
||||||
Member(
|
Member(
|
||||||
id=m.id,
|
id=m.id,
|
||||||
|
member_type=m.member_type,
|
||||||
first_name=m.first_name,
|
first_name=m.first_name,
|
||||||
|
company_name=m.company_name,
|
||||||
last_name=m.last_name,
|
last_name=m.last_name,
|
||||||
street=m.street,
|
street=m.street,
|
||||||
apartment_number=m.apartment_number,
|
apartment_number=m.apartment_number,
|
||||||
@@ -79,8 +83,12 @@ class Mutation:
|
|||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
async def create_member(self, input: CreateMemberInput) -> Member:
|
async def create_member(self, input: CreateMemberInput) -> Member:
|
||||||
"""Create a new member with validation."""
|
"""Create a new member with validation."""
|
||||||
# Validate required field
|
# Validate conditional requirements based on member type
|
||||||
validate_first_name(input.first_name)
|
validate_member_type_requirements(
|
||||||
|
member_type=input.member_type.value,
|
||||||
|
first_name=input.first_name,
|
||||||
|
company_name=input.company_name
|
||||||
|
)
|
||||||
|
|
||||||
# Conditional validation (only if fields are provided)
|
# Conditional validation (only if fields are provided)
|
||||||
validate_email(input.email)
|
validate_email(input.email)
|
||||||
@@ -89,7 +97,9 @@ class Mutation:
|
|||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Create new member
|
# Create new member
|
||||||
db_member = MemberModel(
|
db_member = MemberModel(
|
||||||
|
member_type=input.member_type,
|
||||||
first_name=input.first_name,
|
first_name=input.first_name,
|
||||||
|
company_name=input.company_name,
|
||||||
last_name=input.last_name,
|
last_name=input.last_name,
|
||||||
street=input.street,
|
street=input.street,
|
||||||
apartment_number=input.apartment_number,
|
apartment_number=input.apartment_number,
|
||||||
@@ -106,7 +116,9 @@ class Mutation:
|
|||||||
|
|
||||||
return Member(
|
return Member(
|
||||||
id=db_member.id,
|
id=db_member.id,
|
||||||
|
member_type=db_member.member_type,
|
||||||
first_name=db_member.first_name,
|
first_name=db_member.first_name,
|
||||||
|
company_name=db_member.company_name,
|
||||||
last_name=db_member.last_name,
|
last_name=db_member.last_name,
|
||||||
street=db_member.street,
|
street=db_member.street,
|
||||||
apartment_number=db_member.apartment_number,
|
apartment_number=db_member.apartment_number,
|
||||||
@@ -132,15 +144,29 @@ class Mutation:
|
|||||||
if db_member is None:
|
if db_member is None:
|
||||||
raise MemberNotFoundError(f"Member with ID {input.id} not found")
|
raise MemberNotFoundError(f"Member with ID {input.id} not found")
|
||||||
|
|
||||||
# Validate if fields are being updated
|
# Determine final values for validation (combining existing and new values)
|
||||||
if input.first_name is not None:
|
final_member_type = input.member_type.value if input.member_type is not None else db_member.member_type.value
|
||||||
validate_first_name(input.first_name)
|
final_first_name = input.first_name if input.first_name is not None else db_member.first_name
|
||||||
|
final_company_name = input.company_name if input.company_name is not None else db_member.company_name
|
||||||
|
|
||||||
|
# Validate conditional requirements with final values
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type=final_member_type,
|
||||||
|
first_name=final_first_name,
|
||||||
|
company_name=final_company_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Conditional validation (only if fields are provided)
|
||||||
validate_email(input.email)
|
validate_email(input.email)
|
||||||
validate_phone(input.phone)
|
validate_phone(input.phone)
|
||||||
|
|
||||||
# Update fields if provided
|
# Update fields if provided
|
||||||
|
if input.member_type is not None:
|
||||||
|
db_member.member_type = input.member_type
|
||||||
if input.first_name is not None:
|
if input.first_name is not None:
|
||||||
db_member.first_name = input.first_name
|
db_member.first_name = input.first_name
|
||||||
|
if input.company_name is not None:
|
||||||
|
db_member.company_name = input.company_name
|
||||||
if input.last_name is not None:
|
if input.last_name is not None:
|
||||||
db_member.last_name = input.last_name
|
db_member.last_name = input.last_name
|
||||||
if input.street is not None:
|
if input.street is not None:
|
||||||
@@ -163,7 +189,9 @@ class Mutation:
|
|||||||
|
|
||||||
return Member(
|
return Member(
|
||||||
id=db_member.id,
|
id=db_member.id,
|
||||||
|
member_type=db_member.member_type,
|
||||||
first_name=db_member.first_name,
|
first_name=db_member.first_name,
|
||||||
|
company_name=db_member.company_name,
|
||||||
last_name=db_member.last_name,
|
last_name=db_member.last_name,
|
||||||
street=db_member.street,
|
street=db_member.street,
|
||||||
apartment_number=db_member.apartment_number,
|
apartment_number=db_member.apartment_number,
|
||||||
|
|||||||
+17
-3
@@ -1,15 +1,25 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry.enum
|
||||||
|
class MemberType(str, Enum):
|
||||||
|
"""Member type enumeration."""
|
||||||
|
INDIVIDUAL = "INDIVIDUAL"
|
||||||
|
ORGANIZATION = "ORGANIZATION"
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class Member:
|
class Member:
|
||||||
"""GraphQL Member type."""
|
"""GraphQL Member type."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
first_name: str
|
member_type: MemberType
|
||||||
|
first_name: Optional[str]
|
||||||
|
company_name: Optional[str]
|
||||||
last_name: Optional[str]
|
last_name: Optional[str]
|
||||||
street: Optional[str]
|
street: Optional[str]
|
||||||
apartment_number: Optional[str]
|
apartment_number: Optional[str]
|
||||||
@@ -24,9 +34,11 @@ class Member:
|
|||||||
|
|
||||||
@strawberry.input
|
@strawberry.input
|
||||||
class CreateMemberInput:
|
class CreateMemberInput:
|
||||||
"""Input type for creating a member - only firstName required."""
|
"""Input type for creating a member - conditional requirements based on memberType."""
|
||||||
|
|
||||||
first_name: str
|
member_type: MemberType = MemberType.INDIVIDUAL
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
company_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
street: Optional[str] = None
|
street: Optional[str] = None
|
||||||
apartment_number: Optional[str] = None
|
apartment_number: Optional[str] = None
|
||||||
@@ -42,7 +54,9 @@ class UpdateMemberInput:
|
|||||||
"""Input type for updating a member - all fields optional except id."""
|
"""Input type for updating a member - all fields optional except id."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
member_type: Optional[MemberType] = None
|
||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
|
company_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
street: Optional[str] = None
|
street: Optional[str] = None
|
||||||
apartment_number: Optional[str] = None
|
apartment_number: Optional[str] = None
|
||||||
|
|||||||
+14
-4
@@ -13,10 +13,20 @@ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
|||||||
PHONE_PATTERN = re.compile(r"^\+?[1-9]\d{1,14}$") # E.164 format
|
PHONE_PATTERN = re.compile(r"^\+?[1-9]\d{1,14}$") # E.164 format
|
||||||
|
|
||||||
|
|
||||||
def validate_first_name(first_name: str) -> None:
|
def validate_member_type_requirements(
|
||||||
"""Ensure firstName is provided and non-empty."""
|
member_type: str,
|
||||||
if not first_name or first_name.strip() == "":
|
first_name: Optional[str],
|
||||||
raise ValidationError("firstName cannot be empty")
|
company_name: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
"""Validate conditional requirements based on member type."""
|
||||||
|
if member_type == "INDIVIDUAL":
|
||||||
|
if not first_name or first_name.strip() == "":
|
||||||
|
raise ValidationError("firstName is required for individual members")
|
||||||
|
elif member_type == "ORGANIZATION":
|
||||||
|
if not company_name or company_name.strip() == "":
|
||||||
|
raise ValidationError("companyName is required for organization members")
|
||||||
|
else:
|
||||||
|
raise ValidationError(f"Invalid member type: {member_type}. Must be INDIVIDUAL or ORGANIZATION")
|
||||||
|
|
||||||
|
|
||||||
def validate_email(email: Optional[str]) -> None:
|
def validate_email(email: Optional[str]) -> None:
|
||||||
|
|||||||
@@ -80,5 +80,5 @@ class TestMemberModel:
|
|||||||
repr_str = repr(member)
|
repr_str = repr(member)
|
||||||
assert "Member" in repr_str
|
assert "Member" in repr_str
|
||||||
assert f"id={member.id}" in repr_str
|
assert f"id={member.id}" in repr_str
|
||||||
assert "first_name='David'" in repr_str
|
assert "type=INDIVIDUAL" in repr_str
|
||||||
assert "last_name='Smith'" in repr_str
|
assert "name='David Smith'" in repr_str
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class TestCreateMemberMutation:
|
|||||||
mutation = Mutation()
|
mutation = Mutation()
|
||||||
input_data = CreateMemberInput(first_name="")
|
input_data = CreateMemberInput(first_name="")
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="firstName cannot be empty"):
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
||||||
await mutation.create_member(input=input_data)
|
await mutation.create_member(input=input_data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+105
-20
@@ -2,26 +2,12 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.validation import ValidationError, validate_email, validate_first_name, validate_phone
|
from src.validation import (
|
||||||
|
ValidationError,
|
||||||
|
validate_email,
|
||||||
class TestFirstNameValidation:
|
validate_phone,
|
||||||
"""Tests for validate_first_name function."""
|
validate_member_type_requirements,
|
||||||
|
)
|
||||||
def test_valid_first_name(self):
|
|
||||||
"""Test that valid firstName passes validation."""
|
|
||||||
validate_first_name("Alice")
|
|
||||||
# No exception = pass
|
|
||||||
|
|
||||||
def test_empty_string_raises_error(self):
|
|
||||||
"""Test that empty string raises ValidationError."""
|
|
||||||
with pytest.raises(ValidationError, match="firstName cannot be empty"):
|
|
||||||
validate_first_name("")
|
|
||||||
|
|
||||||
def test_whitespace_only_raises_error(self):
|
|
||||||
"""Test that whitespace-only string raises ValidationError."""
|
|
||||||
with pytest.raises(ValidationError, match="firstName cannot be empty"):
|
|
||||||
validate_first_name(" ")
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmailValidation:
|
class TestEmailValidation:
|
||||||
@@ -110,3 +96,102 @@ class TestPhoneValidation:
|
|||||||
"""Test that empty string passes validation (optional field)."""
|
"""Test that empty string passes validation (optional field)."""
|
||||||
validate_phone("")
|
validate_phone("")
|
||||||
# No exception = pass
|
# No exception = pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestMemberTypeRequirements:
|
||||||
|
"""Tests for validate_member_type_requirements function."""
|
||||||
|
|
||||||
|
def test_individual_with_first_name_valid(self):
|
||||||
|
"""Test INDIVIDUAL with firstName passes."""
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="INDIVIDUAL",
|
||||||
|
first_name="John",
|
||||||
|
company_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_individual_without_first_name_invalid(self):
|
||||||
|
"""Test INDIVIDUAL without firstName fails."""
|
||||||
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="INDIVIDUAL",
|
||||||
|
first_name=None,
|
||||||
|
company_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_individual_with_empty_first_name_invalid(self):
|
||||||
|
"""Test INDIVIDUAL with empty firstName fails."""
|
||||||
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="INDIVIDUAL",
|
||||||
|
first_name="",
|
||||||
|
company_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_individual_with_whitespace_first_name_invalid(self):
|
||||||
|
"""Test INDIVIDUAL with whitespace-only firstName fails."""
|
||||||
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="INDIVIDUAL",
|
||||||
|
first_name=" ",
|
||||||
|
company_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_organization_with_company_name_valid(self):
|
||||||
|
"""Test ORGANIZATION with companyName passes."""
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="ORGANIZATION",
|
||||||
|
first_name=None,
|
||||||
|
company_name="Acme Corp"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_organization_without_company_name_invalid(self):
|
||||||
|
"""Test ORGANIZATION without companyName fails."""
|
||||||
|
with pytest.raises(ValidationError, match="companyName is required for organization members"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="ORGANIZATION",
|
||||||
|
first_name=None,
|
||||||
|
company_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_organization_with_empty_company_name_invalid(self):
|
||||||
|
"""Test ORGANIZATION with empty companyName fails."""
|
||||||
|
with pytest.raises(ValidationError, match="companyName is required for organization members"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="ORGANIZATION",
|
||||||
|
first_name=None,
|
||||||
|
company_name=""
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_organization_with_whitespace_company_name_invalid(self):
|
||||||
|
"""Test ORGANIZATION with whitespace-only companyName fails."""
|
||||||
|
with pytest.raises(ValidationError, match="companyName is required for organization members"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="ORGANIZATION",
|
||||||
|
first_name=None,
|
||||||
|
company_name=" "
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_member_type_raises_error(self):
|
||||||
|
"""Test invalid member type raises ValidationError."""
|
||||||
|
with pytest.raises(ValidationError, match="Invalid member type"):
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="INVALID",
|
||||||
|
first_name="John",
|
||||||
|
company_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_organization_can_have_first_name_optional(self):
|
||||||
|
"""Test ORGANIZATION can optionally have firstName."""
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="ORGANIZATION",
|
||||||
|
first_name="John Doe",
|
||||||
|
company_name="Acme Corp"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_individual_can_have_company_name_optional(self):
|
||||||
|
"""Test INDIVIDUAL can optionally have companyName (edge case)."""
|
||||||
|
validate_member_type_requirements(
|
||||||
|
member_type="INDIVIDUAL",
|
||||||
|
first_name="John",
|
||||||
|
company_name="Acme Corp"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user