Write Endpoints and Multi-Company Verification

Add POST and PATCH endpoints to a multi-tenant REST API with company-scoped isolation.

Introduction

30 Second Summary

Imagine a shared office building where every company has its own locked filing cabinet. Reading someone else's files by mistake is bad. But accidentally filing your documents in the wrong cabinet is worse, because now the records are silently corrupted and nobody knows.

In this project, you will add write operations to a mock API that serves multiple companies from one database. You'll build POST and PATCH endpoints that enforce company-scoping on every create and update, then write automated tests proving cross-company writes are structurally impossible.

What You'll Build

You'll POST a new customer to company 1's endpoint, see a 201 response with a Location header pointing to the new resource, then try the same GET under company 2's path and watch it return 404. That single test proves the isolation holds.

By the end of this project, you'll have:

  • POST endpoints for customers, jobs, and quotes that return 201 with a Location header and the created resource in the body, enforcing company-scoping entirely from the URL path.
  • PATCH endpoints that accept partial updates via exclude_unset semantics, return 204, and reject cross-company modification attempts with 404.
  • An automated isolation test suite that creates resources under one company, attempts cross-company reads, writes, and foreign key references, and proves each attempt returns 404.
  • Secret Mission: Add body-injection defense tests proving a CompanyId field in the request body is structurally ignored, plus edge-case validation confirming empty PATCH bodies are rejected while intentional null-clears are accepted.

Are there any prerequisites?

This project picks up where Phase 3 Sprint 2 left off. You need the simpro_mock service running with 8 GET endpoints, multi-company seed data, BearerAuthMiddleware, pagination, and filtering, with your develop branch up to date.

Before We Start

Before we dive in, let's lock in what you're building this sprint and why it matters. Write operations introduce a new category of risk: a naive endpoint that looks up by resource ID alone could let a request to one company's URL modify or create a resource belonging to a different company.

Add Pydantic Request Models with PascalCase Aliases

Simpro's real API accepts PascalCase request bodies. The mock currently has response schemas for reads, but no request models at all. Without dedicated request models, any write endpoint you add would either reject valid payloads from simpro_client or accept completely unvalidated input.

In this step, you'll create Pydantic request models with PascalCase aliases that match the wire format. You'll split them into Create models (for POST, with required fields) and Update models (for PATCH, with all-optional fields and emptiness checks).

In this step, get ready to:
  • Create a SimproRequestBase with PascalCase alias generation and body-injection defense.
  • Define Create and Update request models for customers, jobs, and quotes.
  • Verify PascalCase aliases appear in the model schema output.
Create the SimproRequestBase with PascalCase aliases

Every request body that simpro_client sends will use PascalCase field names like CompanyName and GivenName. Your base model needs to handle the conversion between Python's snake_case and the wire format's PascalCase automatically.

  • Open simpro_mock/schemas.py in your editor.
  • Scroll to the bottom of the file, below all existing response schemas.
  • Add the Sprint 3 imports, the PascalCase helper function, and the base request model by pasting this code at the end of the file:
# --- Sprint 3 additions below existing response schemas ---

from pydantic import BaseModel, ConfigDict, model_validator

def to_pascal(snake: str) -> str:
    """Convert snake_case to PascalCase."""
    return "".join(word.capitalize() for word in snake.split("_"))

class SimproRequestBase(BaseModel):
    """
    Base for all Simpro mock request bodies.

    Design decisions:
    - PascalCase aliases match Simpro's real wire format (consistent with
      existing response models). simpro_client will send PascalCase.
    - company_id is structurally absent from all request models, not
      validated-against-and-rejected. extra="ignore" silently drops any
      body-injected fields like CompanyId.
    """

    model_config = ConfigDict(
        alias_generator=to_pascal,
        populate_by_name=True,
        extra="ignore",
    )

What does this code do?

to_pascal() converts snake_case field names to PascalCase. For example, company_name becomes CompanyName on the wire.

The three ConfigDict settings each serve a specific purpose:

  • alias_generator=to_pascal makes Pydantic accept PascalCase keys in incoming JSON and produce PascalCase in output.
  • populate_by_name=True lets you also use the Python field name (company_name) when constructing the model in code.
  • extra="ignore" silently drops any field not defined on the model. This is the structural defense against body-injected fields like CompanyId.

Why is company_id absent from request models instead of validated?

The company_id always comes from the URL path parameter, never from the request body. Instead of defining a company_id field and rejecting it when it's sent, the field simply doesn't exist on the model. Combined with extra="ignore", a client sending CompanyId in the body gets it silently dropped. The defense is structural, not validation-based.

  • Save schemas.py.
  • Confirm the base model loads by running:
docker compose exec simpro-mock python -c "from simpro_mock.schemas import SimproRequestBase; print(SimproRequestBase.model_config)"

You should see the config dict printed, including alias_generator, populate_by_name, and extra settings.

Seeing an ImportError?

  • Check that you pasted the code below the existing response schemas, not inside one of them.
  • Make sure the from pydantic import BaseModel, ConfigDict, model_validator line is present. The existing file may already import BaseModel, but ConfigDict and model_validator are new imports for Sprint 3.
  • Verify the to_pascal function is defined before SimproRequestBase since the class references it in alias_generator.

Help me debug an ImportError in schemas.py after adding SimproRequestBase

Define Create and Update models for customers, jobs, and quotes

POST and PATCH operations need different validation rules. A POST (Create) requires certain fields like company_name for a customer. A PATCH (Update) makes every field optional but must reject a completely empty body.

  • Add the Customer request models below the SimproRequestBase class in schemas.py:
# --- Customer ---

class CustomerCreate(SimproRequestBase):
    company_name: str
    given_name: str | None = None
    family_name: str | None = None
    email: str | None = None
    phone: str | None = None

class CustomerUpdate(SimproRequestBase):
    company_name: str | None = None
    given_name: str | None = None
    family_name: str | None = None
    email: str | None = None
    phone: str | None = None

    @model_validator(mode="after")
    def check_not_empty(self) -> "CustomerUpdate":
        if not self.model_dump(exclude_unset=True):
            raise ValueError("PATCH body must contain at least one field")
        return self

Why exclude_unset instead of a None-check?

The model_validator on CustomerUpdate checks self.model_dump(exclude_unset=True) for emptiness. A None-check would fail here because it can't distinguish "field omitted" from "field explicitly sent as null."

A client sending {"Email": null} to clear a customer's email is a valid partial update. The exclude_unset approach correctly rejects only genuinely empty PATCH bodies ({}) while allowing intentional null-clears.

  • Save schemas.py.
  • Confirm the Customer models load and produce PascalCase aliases by running:
docker compose exec simpro-mock python -c "from simpro_mock.schemas import CustomerCreate; print(CustomerCreate.model_json_schema())"

You should see a JSON schema with PascalCase property names like CompanyName, GivenName, and FamilyName. The alias generator is working.

Seeing snake_case field names instead of PascalCase?

  • Verify that CustomerCreate inherits from SimproRequestBase, not BaseModel directly.
  • Check that SimproRequestBase has alias_generator=to_pascal in its model_config.

Why is CustomerCreate showing snake_case instead of PascalCase aliases?

The Job and Quote models follow the same pattern. The key difference is that JobCreate and QuoteCreate both require a customer_id field, since every job and quote belongs to a customer.

  • Add the Job request models below the Customer models in schemas.py:
# --- Job ---

class JobCreate(SimproRequestBase):
    customer_id: int
    description: str | None = None
    status: str | None = None
    site_name: str | None = None

class JobUpdate(SimproRequestBase):
    description: str | None = None
    status: str | None = None
    site_name: str | None = None

    @model_validator(mode="after")
    def check_not_empty(self) -> "JobUpdate":
        if not self.model_dump(exclude_unset=True):
            raise ValueError("PATCH body must contain at least one field")
        return self

What does this code do?

  • JobCreate requires customer_id: int because every job must reference the customer it belongs to. The POST handler will validate this ID is company-scoped in a later step.
  • JobUpdate omits customer_id. Reassigning a job to a different customer via PATCH is not a supported operation.
  • The same check_not_empty validator rejects empty PATCH bodies, identical to the Customer pattern.
  • Add the Quote request models below the Job models in schemas.py:
# --- Quote ---

class QuoteCreate(SimproRequestBase):
    customer_id: int
    description: str | None = None
    status: str | None = None
    total: float | None = None

class QuoteUpdate(SimproRequestBase):
    description: str | None = None
    status: str | None = None
    total: float | None = None

    @model_validator(mode="after")
    def check_not_empty(self) -> "QuoteUpdate":
        if not self.model_dump(exclude_unset=True):
            raise ValueError("PATCH body must contain at least one field")
        return self

What does this code do?

  • QuoteCreate mirrors JobCreate with a required customer_id. It also adds a total: float field for the quote's monetary value.
  • QuoteUpdate follows the same exclude_unset emptiness check pattern as the other Update models.
  • Save schemas.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved schemas.py and move on to the verification step below.

ⓧ I'd like to double check the full code

Scroll up through your Sprint 3 additions in schemas.py and confirm each section is present in this order:

  • The # --- Sprint 3 additions comment, the from pydantic import line, the to_pascal() function, and the SimproRequestBase class with its docstring and model_config.
  • CustomerCreate (with required company_name: str) and CustomerUpdate (all optional, with check_not_empty validator).
  • JobCreate (with required customer_id: int) and JobUpdate (with check_not_empty validator).
  • QuoteCreate (with required customer_id: int and optional total: float) and QuoteUpdate (with check_not_empty validator).
Verify PascalCase aliases across all models

All six request models are in place. The design decisions are captured in the SimproRequestBase docstring: PascalCase aliases for wire-format consistency, and company_id structurally absent from all request models. Now restart the container so the running FastAPI app loads the new schemas.

  • Restart the mock service by running:
docker compose restart simpro-mock

Before you run the next command, think about what you expect to see. Will the schema output show company_name or CompanyName? Why?

  • Verify the schema output by running:
docker compose exec simpro-mock python -c "from simpro_mock.schemas import CustomerCreate; print(CustomerCreate.model_json_schema())"

The schema output shows PascalCase property names: CompanyName, GivenName, FamilyName. The alias_generator is converting every snake_case field name to PascalCase, which matches how Simpro's real API expects request bodies.

Container failed to restart?

  • Run docker compose logs simpro-mock --tail 20 to check for syntax errors in schemas.py that prevent the app from starting.
  • A common cause is a missing closing parenthesis on the ConfigDict() call or a misindented @model_validator decorator.

Help me fix a container restart failure after adding request models to schemas.py

Your request models are ready. They accept PascalCase input, enforce required fields on POST, reject empty PATCH bodies, and structurally ignore any body-injected CompanyId. Next up, you'll wire these models into actual POST endpoints that return 201 Created with a Location header.

Implement POST Endpoints with 201 + Location Header

Your request models are in place. CustomerCreate, JobCreate, and QuoteCreate define what a valid POST body looks like, and the Update models handle partial PATCH payloads.

The mock still only serves GET requests. Without POST endpoints, there is no way to test that simpro_client's future create operations send the right payload and handle the response correctly. This step adds the create path and immediately proves it works by returning a 201 Created status with a Location header and the new resource in the response body.

In this step, get ready to:
  • Add helper functions that enforce company-scoped lookups before any write.
  • Build POST endpoints for customers, jobs, and quotes that return 201 with a Location header.
  • Fix a PostgreSQL sequence collision that blocks the first POST on a freshly seeded database.
Add company-scoping helper functions

Every POST handler needs to answer two questions before it creates anything. Does the company in the URL actually exist? And for jobs and quotes, does the customer_id in the body belong to that same company? Two small helpers will handle both checks.

  • Open simpro_mock/routers.py and scroll to the bottom, below all existing GET route handlers.
  • Add the Sprint 3 imports and the first helper function by pasting this code at the bottom of the file:
# --- Sprint 3 additions below existing GET route handlers ---

from fastapi import HTTPException, Response
from fastapi.responses import JSONResponse

from simpro_mock.schemas import (
    CustomerCreate, CustomerUpdate,
    JobCreate, JobUpdate,
    QuoteCreate, QuoteUpdate,
)


def _get_company_or_404(company_id: int, session) -> None:
    # Intentionally stricter than GET list endpoints, which return empty lists
    from simpro_mock.models import Company
    from sqlalchemy import select

    company = session.execute(
        select(Company).where(Company.id == company_id)
    ).scalar_one_or_none()
    if company is None:
        raise HTTPException(status_code=404, detail="Company not found")

What does this code do?

The imports pull in the response classes and all six request schemas you built in Step 1.

_get_company_or_404 queries the companies table and raises a 404 if the company_id from the URL path does not exist. This is stricter than the existing GET list endpoints, which return an empty list for a nonexistent company. A POST that silently creates resources under a nonexistent company would be a data integrity failure.

  • Directly below _get_company_or_404, add the second helper that validates cross-company customer references:
def _get_customer_in_company_or_404(
    customer_id: int, company_id: int, session
) -> None:
    # Compound WHERE: FK alone only proves customer exists somewhere
    from simpro_mock.models import Customer
    from sqlalchemy import select

    customer = session.execute(
        select(Customer).where(
            Customer.id == customer_id,
            Customer.company_id == company_id,
        )
    ).scalar_one_or_none()
    if customer is None:
        raise HTTPException(
            status_code=404,
            detail="Customer not found in this company",
        )

Why a compound WHERE instead of just a foreign key check?

A foreign key constraint only proves a customer row exists somewhere in the database. It does not prove the customer belongs to the company in the URL path. Without the compound WHERE id = :customer_id AND company_id = :company_id, a client could create a quote under company 1 that references a customer belonging to company 2.

  • Save routers.py.
  • Restart the service to pick up your changes by running:
docker compose restart simpro-mock
  • Confirm the service restarts cleanly by running a quick GET request:
curl -s http://localhost:8100/api/v1.0/companies/1/customers/ -H "Authorization: Bearer static-dev-token" | head -c 200

You should see the same JSON customer data from Sprint 2. The helpers are loaded but not called yet.

Service not restarting?

Check your terminal for a syntax error in the imports or helper functions. The most common cause is a missing comma between the schema imports or a mismatched parenthesis in the select().where() call.

Check the container logs for details by running docker compose logs simpro-mock --tail 30.

Why won't my simpro-mock container restart after adding the helper functions?

Add the customer POST endpoint and hit the seed collision

The first POST endpoint handles customer creation. The critical design rule: company_id always comes from the URL path, never from the request body.

  • In routers.py, add the customer POST handler directly below _get_customer_in_company_or_404:
@router.post(
    "/api/v1.0/companies/{company_id}/customers/",
    status_code=201,
)
def create_customer(company_id: int, body: CustomerCreate):
    from simpro_mock.database import SessionLocal
    from simpro_mock.models import Customer

    with SessionLocal() as session:
        _get_company_or_404(company_id, session)
        # company_id comes from URL path, never from request body
        customer = Customer(
            company_id=company_id,
            company_name=body.company_name,
            given_name=body.given_name,
            family_name=body.family_name,
            email=body.email,
            phone=body.phone,
        )
        session.add(customer)
        session.flush()  # PostgreSQL RETURNING populates customer.id
        new_id = customer.id
        session.commit()
    location = f"/api/v1.0/companies/{company_id}/customers/{new_id}"
    return JSONResponse(
        status_code=201,
        content={"ID": new_id, "CompanyName": body.company_name},
        headers={"Location": location},
    )

What does this handler do?

  • _get_company_or_404 rejects writes to nonexistent companies before any row is inserted.
  • session.add() stages the new row. session.flush() sends the INSERT to PostgreSQL and populates customer.id via the RETURNING clause.
  • The response is a JSONResponse with status 201, a Location header pointing to the new resource's URL, and the created resource in the body.
  • Save routers.py and restart the service:
docker compose restart simpro-mock

Before you run the next command, what status code do you expect the POST to return? 201, 404, or something else?

  • Test the new customer POST endpoint by running:
curl -s -D- -X POST http://localhost:8100/api/v1.0/companies/1/customers/ -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"CompanyName":"Test Corp"}'

You get a 500 Internal Server Error instead of a 201. This is not a bug in your handler. This is a primary key collision caused by the seed data.

Why did the POST fail with a primary key collision?

PostgreSQL SERIAL columns use a sequence to generate default IDs. When seed.py inserted rows with hardcoded IDs (1, 2, 3, etc.), it bypassed nextval() entirely. The sequence was never advanced.

Your POST called nextval() for the first time, got back ID 1, and tried to insert it into a table where ID 1 already exists. The fix is to sync each table's sequence to the highest existing ID after seeding.

Fix the sequence collision in seed.py
  • Open simpro_mock/seed.py and scroll to the end of the seed function, after all the insert statements but before the final commit.
  • Add this sequence-sync block:
# --- Append after all seed inserts, before final commit ---

from sqlalchemy import text

# Sync sequences after hardcoded ID inserts so nextval() doesn't collide
for table in ("companies", "customers", "jobs", "quotes"):
    db.execute(text(
        f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
        f"COALESCE((SELECT MAX(id) FROM {table}), 1))"
    ))
db.commit()

What does this fix do?

  • pg_get_serial_sequence() finds the sequence name associated with each table's id column.
  • setval() advances the sequence to the highest existing ID. The two-argument form sets is_called=true, so the next nextval() returns max + 1 instead of colliding with an existing row.
  • COALESCE() handles the edge case where a table has no rows yet, defaulting the sequence to 1.
  • Save seed.py.
  • Bring the containers down and back up so the database is recreated with the updated seed logic:
docker compose down
docker compose up -d
  • Wait a few seconds for the database to seed, then retry the same POST:
curl -s -D- -X POST http://localhost:8100/api/v1.0/companies/1/customers/ -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"CompanyName":"Test Corp"}'

This time you see HTTP/1.1 201 Created in the response headers. You also see a Location header with the new customer's URL and the created resource in the body.

Still seeing a 500 error?

Make sure you ran docker compose down before docker compose up -d. A simple restart does not recreate the database, so the old un-synced sequences persist.

Check the container logs with docker compose logs simpro-mock --tail 30 to see if the seed ran successfully.

Why is my POST still returning 500 after fixing seed.py?

✔️ Awesome, I've got everything!

Great. Double check you have saved seed.py.

ⓧ I'd like to double check the full code

Here is the complete sequence-sync block you added to seed.py:

# --- Append after all seed inserts, before final commit ---

from sqlalchemy import text

# Sync sequences after hardcoded ID inserts so nextval() doesn't collide
for table in ("companies", "customers", "jobs", "quotes"):
    db.execute(text(
        f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
        f"COALESCE((SELECT MAX(id) FROM {table}), 1))"
    ))
db.commit()
Add job and quote POST endpoints

The job and quote endpoints follow the same pattern as the customer endpoint. The key difference is both require a customer_id in the body, which must be validated against the URL's company to prevent cross-company references.

  • In routers.py, add the job POST handler directly below create_customer:
@router.post(
    "/api/v1.0/companies/{company_id}/jobs/",
    status_code=201,
)
def create_job(company_id: int, body: JobCreate):
    from simpro_mock.database import SessionLocal
    from simpro_mock.models import Job

    with SessionLocal() as session:
        _get_company_or_404(company_id, session)
        # Validate customer_id belongs to this company, not just any company
        _get_customer_in_company_or_404(body.customer_id, company_id, session)
        job = Job(
            company_id=company_id,
            customer_id=body.customer_id,
            description=body.description,
            status=body.status or "Pending",
            site_name=body.site_name,
        )
        session.add(job)
        session.flush()
        new_id = job.id
        session.commit()
    location = f"/api/v1.0/companies/{company_id}/jobs/{new_id}"
    return JSONResponse(
        status_code=201,
        content={"ID": new_id},
        headers={"Location": location},
    )

What is different from the customer handler?

The call to _get_customer_in_company_or_404 uses the compound WHERE pattern to verify that customer_id from the body actually belongs to the company in the URL. If a client tries to create a job under company 1 referencing a customer that belongs to company 2, this check returns 404 before the row is created.

  • Save routers.py and restart the service:
docker compose restart simpro-mock
  • Test the job POST endpoint by running:
curl -s -D- -X POST http://localhost:8100/api/v1.0/companies/1/jobs/ -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"CustomerId":1,"Description":"Test Job"}'

You see 201 Created with a Location header pointing to the new job's URL. The CustomerId of 1 resolved because customer 1 belongs to company 1.

  • Now add the quote POST handler directly below create_job:
@router.post(
    "/api/v1.0/companies/{company_id}/quotes/",
    status_code=201,
)
def create_quote(company_id: int, body: QuoteCreate):
    from simpro_mock.database import SessionLocal
    from simpro_mock.models import Quote

    with SessionLocal() as session:
        _get_company_or_404(company_id, session)
        # Same cross-company customer_id check as create_job
        _get_customer_in_company_or_404(body.customer_id, company_id, session)
        quote = Quote(
            company_id=company_id,
            customer_id=body.customer_id,
            description=body.description,
            status=body.status or "Draft",
            total=body.total or 0.0,
        )
        session.add(quote)
        session.flush()
        new_id = quote.id
        session.commit()
    location = f"/api/v1.0/companies/{company_id}/quotes/{new_id}"
    return JSONResponse(
        status_code=201,
        content={"ID": new_id},
        headers={"Location": location},
    )

What does this code do?

Identical pattern to create_job. The only differences are the default status ("Draft" instead of "Pending") and the total field defaulting to 0.0. Both still run the compound WHERE check on customer_id.

  • Save routers.py and restart the service:
docker compose restart simpro-mock

Before you run the final verification, what do you expect the response status to be?

  • Test the quote POST endpoint by running:
curl -s -D- -X POST http://localhost:8100/api/v1.0/companies/1/quotes/ -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"CustomerId":1,"Description":"Test Quote"}'

You see 201 Created with a Location header. All three POST endpoints are live.

Getting a 404 on the quote POST?

A 404 means _get_customer_in_company_or_404 could not find CustomerId 1 under company 1. Confirm your seeded data includes a customer with ID 1 in company 1 by running a quick GET:

curl -s http://localhost:8100/api/v1.0/companies/1/customers/1 -H "Authorization: Bearer static-dev-token"

If that also returns 404, your seed data may not have loaded. Run docker compose down && docker compose up -d to recreate the database.

Why is my quote POST returning 404 when the customer exists?

✔️ Awesome, I've got everything!

Great. Double check you have saved routers.py with all three POST handlers.

ⓧ I'd like to double check the full code

Scroll through the Sprint 3 additions in routers.py and confirm each of these blocks is present, in this order:

  • Imports: HTTPException, Response, JSONResponse, and all six schema classes.
  • Helper: _get_company_or_404 querying the companies table with a single WHERE on Company.id.
  • Helper: _get_customer_in_company_or_404 querying with compound WHERE on Customer.id and Customer.company_id.
  • POST handler: create_customer returning JSONResponse(status_code=201) with Location header.
  • POST handler: create_job with _get_customer_in_company_or_404 call before insert.
  • POST handler: create_quote with the same _get_customer_in_company_or_404 call.

Your mock can now create company-scoped resources and return proper REST responses. Next up, you will add PATCH endpoints that update existing resources while enforcing the same company isolation on writes.

Implement PATCH Endpoints with 204 + Company-Scoped Lookup

The POST endpoints from Step 2 let the mock create resources. But there is no way to update existing ones, which means simpro_client's future update workflows have nothing to test against.

PATCH is where company-scoping becomes genuinely dangerous. A naive PATCH that looks up a resource by ID alone would let a request to /companies/1/jobs/17 silently modify a job that actually belongs to company 2. The lookup query must filter on both id and company_id in a single query.

In this step, get ready to:
  • Add three PATCH route handlers that return 204 No Content.
  • Use compound WHERE queries to enforce company-scoped lookups on updates.
  • Verify that a cross-company PATCH attempt returns 404.
Add the PATCH customer handler

Each PATCH handler follows a single-query pattern: look up the resource by both id and company_id in one WHERE clause. If the row doesn't match, return 404. This covers both "doesn't exist" and "exists but belongs to a different company" without leaking which case it was.

  • In routers.py, scroll to the bottom of the Sprint 3 additions and find the create_quote function.
  • Add the following PATCH customer handler below create_quote:
# --- PATCH Customers ---

@router.patch(
    "/api/v1.0/companies/{company_id}/customers/{customer_id}",
    status_code=204,
)
def update_customer(company_id: int, customer_id: int, body: CustomerUpdate):
    from simpro_mock.database import SessionLocal
    from simpro_mock.models import Customer
    from sqlalchemy import select

    with SessionLocal() as session:
        _get_company_or_404(company_id, session)
        # Compound WHERE: match both id AND company_id in one query
        customer = session.execute(
            select(Customer).where(
                Customer.id == customer_id,
                Customer.company_id == company_id,
            )
        ).scalar_one_or_none()
        if customer is None:
            raise HTTPException(status_code=404, detail="Customer not found")
        # Apply only the fields the client actually sent
        for field, value in body.model_dump(exclude_unset=True).items():
            setattr(customer, field, value)
        session.commit()
    return Response(status_code=204)

What does this code do?

The compound WHERE clause WHERE id = :id AND company_id = :company_id is a single query, not a two-step "find by ID then check company" flow. This avoids timing side-channels that could leak whether a resource exists in another company.

The model_dump(exclude_unset=True) call returns only fields the client actually sent. Omitted fields are left untouched, while explicitly-null fields (like {"Email": null}) are applied as null-clears. This is the same exclude_unset pattern from the Update model validators you built in Step 1.

Finally, Response(status_code=204) returns an empty body. PATCH returns 204 No Content because the client already knows what it sent.

  • Save routers.py.
  • Restart the container to pick up the new handler by running:
docker compose restart simpro-mock
  • Test the PATCH customer endpoint by running:
curl -s -o /dev/null -w "%{http_code}" -X PATCH http://localhost:8100/api/v1.0/companies/1/customers/1 -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"CompanyName":"Updated Name"}'

You should see 204 printed to the terminal.

  • Confirm the update actually stuck by running a GET on the same resource:
curl -s http://localhost:8100/api/v1.0/companies/1/customers/1 -H "Authorization: Bearer static-dev-token" | python3 -m json.tool

You should see "CompanyName": "Updated Name" in the JSON response. The PATCH wrote through to the database.

Seeing 405 Method Not Allowed?

This means FastAPI doesn't recognize the PATCH route. Make sure the decorator uses @router.patch (not @router.post) and that you restarted the container after saving.

If you see 404 instead, check that the route path includes {customer_id} (not {id}) and matches the function parameter name exactly.

Still stuck? Help me debug my PATCH customer endpoint. I'm getting an unexpected status code when I curl PATCH /companies/1/customers/1.

Add the PATCH job and quote handlers

The job and quote PATCH handlers follow the same compound-WHERE and exclude_unset pattern. The only differences are the model class, the URL parameter name, and the 404 detail message.

  • In routers.py, add the PATCH job handler below update_customer:
# --- PATCH Jobs ---

@router.patch(
    "/api/v1.0/companies/{company_id}/jobs/{job_id}",
    status_code=204,
)
def update_job(company_id: int, job_id: int, body: JobUpdate):
    from simpro_mock.database import SessionLocal
    from simpro_mock.models import Job
    from sqlalchemy import select

    with SessionLocal() as session:
        _get_company_or_404(company_id, session)
        # Same compound-WHERE pattern: id + company_id
        job = session.execute(
            select(Job).where(
                Job.id == job_id,
                Job.company_id == company_id,
            )
        ).scalar_one_or_none()
        if job is None:
            raise HTTPException(status_code=404, detail="Job not found")
        for field, value in body.model_dump(exclude_unset=True).items():
            setattr(job, field, value)
        session.commit()
    return Response(status_code=204)

What changed from the customer handler?

Only three things: the model is Job instead of Customer, the URL parameter is job_id, and the 404 detail says "Job not found". The query shape and update logic are identical.

  • Add the PATCH quote handler below update_job:
# --- PATCH Quotes ---

@router.patch(
    "/api/v1.0/companies/{company_id}/quotes/{quote_id}",
    status_code=204,
)
def update_quote(company_id: int, quote_id: int, body: QuoteUpdate):
    from simpro_mock.database import SessionLocal
    from simpro_mock.models import Quote
    from sqlalchemy import select

    with SessionLocal() as session:
        _get_company_or_404(company_id, session)
        # Same compound-WHERE pattern: id + company_id
        quote = session.execute(
            select(Quote).where(
                Quote.id == quote_id,
                Quote.company_id == company_id,
            )
        ).scalar_one_or_none()
        if quote is None:
            raise HTTPException(status_code=404, detail="Quote not found")
        for field, value in body.model_dump(exclude_unset=True).items():
            setattr(quote, field, value)
        session.commit()
    return Response(status_code=204)

What does this code do?

Same pattern again. Quote model, quote_id URL parameter, "Quote not found" detail message. All three PATCH handlers now share the same compound-WHERE and exclude_unset update logic.

✔️ Awesome, I've got everything!

Great. Double check you have saved routers.py before continuing.

ⓧ I'd like to double check the full code

The Sprint 3 additions in routers.py are now too long for a single view. Scroll through the file and confirm you have the following sections in order:

  • Imports: HTTPException, Response, JSONResponse, and all six schema classes.
  • Helper functions: _get_company_or_404 and _get_customer_in_company_or_404.
  • POST handlers: create_customer, create_job, create_quote.
  • PATCH handlers: update_customer, update_job, update_quote.
  • Restart the container to load all three PATCH handlers by running:
docker compose restart simpro-mock
  • Verify the PATCH job endpoint works by running:
curl -s -o /dev/null -w "%{http_code}" -X PATCH http://localhost:8100/api/v1.0/companies/2/jobs/17 -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"Description":"Updated via PATCH"}'

You should see 204. The job handler uses the same compound-WHERE pattern and returns 204 No Content.

Seeing 422 instead of 204?

A 422 means Pydantic rejected the request body. Check that your JSON field name uses PascalCase (e.g. Description, not description). The alias generator expects PascalCase on the wire.

Still stuck? Help me debug why my PATCH job endpoint returns 422 instead of 204.

Verify cross-company PATCH rejection

The happy path works. Now for the real question: can a request to company 2's URL modify company 1's customer? This is the test that proves the compound WHERE is doing its job.

Before you run the next command, predict: what status code will you get when you PATCH /companies/2/customers/1 for a customer that belongs to company 1?

  • Attempt a cross-company PATCH by running:
curl -s -o /dev/null -w "%{http_code}" -X PATCH http://localhost:8100/api/v1.0/companies/2/customers/1 -H "Authorization: Bearer static-dev-token" -H "Content-Type: application/json" -d '{"CompanyName":"Hijacked"}'

You see 404. Customer 1 belongs to company 1, so the compound WHERE WHERE id = 1 AND company_id = 2 found no matching row. The response is identical to "resource doesn't exist at all." No information leaked about whether customer 1 exists in another company.

Why a single query instead of lookup-then-check?

A two-step approach (find by ID, then check company) would return slightly different response times depending on whether the resource existed at all vs. existed in the wrong company. An attacker could use that timing difference to enumerate resources across companies.

The single-query approach collapses both cases into the same code path and the same 404 response. This is the same compound-WHERE pattern you used in _get_customer_in_company_or_404 for POST validation. No new abstraction needed.

All three PATCH endpoints are live and company-scoped. Next up, you'll write automated tests that exercise both companies and prove the isolation holds under every combination of cross-company write attempts.

Write Multi-Company Isolation Tests

Your POST and PATCH endpoints are live, and the manual curl tests from the previous steps proved they work for the happy path. But a manual curl only proves what you tested. It says nothing about what happens when a request arrives at the wrong company's URL.

Write operations introduce a category of risk that reads don't have. A POST could silently assign the wrong company_id. A PATCH could modify a resource across company boundaries. A customer_id foreign key could reference across companies. Without automated tests that exercise both companies and attempt cross-company operations, there is no proof the scoping holds.

In this step, get ready to:
  • Write POST-PATCH-GET round-trip tests for customers, jobs, and quotes.
  • Write cross-company isolation tests that prove writes to the wrong company return 404.
  • Write validation edge cases and a sequence-collision regression test.
Write POST-PATCH-GET round-trip tests

A round-trip test proves the full lifecycle: POST creates a resource, PATCH updates it, and GET confirms the update stuck. Since the 201 response body includes the created resource, no extra GET is needed after the POST.

The existing Sprint 2 tests in tests/test_simpro_mock.py already define a client fixture. All the new tests reuse it.

  • In tests/test_simpro_mock.py, scroll to the bottom of the file (below the existing Sprint 2 tests).
  • Add the customer write round-trip test class by pasting the following code at the end of the file:
class TestCustomerWriteCompany1:
    """POST-PATCH-GET round-trip for customers under company 1."""

    def test_create_update_read_customer(self, client):
        # POST a new customer to company 1
        resp = client.post(
            "/api/v1.0/companies/1/customers/",
            json={"CompanyName": "Round Trip Corp"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 201
        assert resp.json()["CompanyName"] == "Round Trip Corp"
        location = resp.headers["Location"]

        # PATCH the customer's company name
        patch_resp = client.patch(
            location,
            json={"CompanyName": "Updated Corp"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert patch_resp.status_code == 204

        # GET and confirm the update took effect
        get_resp = client.get(
            location,
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert get_resp.status_code == 200
        assert get_resp.json()["CompanyName"] == "Updated Corp"

What does this test prove?

The test exercises the full create-update-read lifecycle in a single method. It asserts 201 on POST (resource created), checks the response body matches the sent data, asserts 204 on PATCH (update applied), and finally GETs to confirm the field actually changed in the database.

The Location header from the 201 response doubles as the URL for the PATCH and GET calls. No ID parsing needed.

  • Save tests/test_simpro_mock.py.
  • Run the test suite to confirm the new test passes by running this command:
docker compose exec simpro-mock pytest tests/ -v -k TestCustomerWriteCompany1

You should see 1 passed for test_create_update_read_customer.

Seeing a test failure?

  • If you see 401, make sure the headers dict includes the Authorization key with value Bearer static-dev-token on every request.
  • If you see a 500 or an IntegrityError, confirm that the sequence-sync code in seed.py from Step 2 is in place. Restart the containers with docker compose down && docker compose up -d to re-seed.

Still stuck? Ask for help.

Now write the same round-trip pattern for jobs and quotes. The job test targets company 2 (to prove writes work under either company). The quote test targets company 1. Both resources require a customer_id on POST, so each test must first create a customer under the same company to get a valid FK.

✔️ I'll give it a go!

  • Add a TestJobWriteCompany2 class that POSTs a customer to company 2, POSTs a job using that customer's ID, PATCHes the job's description, and GETs to confirm the update.
  • Add a TestQuoteWriteCompany1 class that follows the same pattern for quotes under company 1, PATCHing the quote's status field.
  • Remember: JSON keys use PascalCase (CustomerId, Description, Status) to match Simpro's wire format.

ⓧ Let me check my work

Here is the job round-trip test class. Add it below TestCustomerWriteCompany1:

class TestJobWriteCompany2:
    """POST-PATCH-GET round-trip for jobs under company 2."""

    def test_create_update_read_job(self, client):
        # Create a customer under company 2 for the FK
        cust_resp = client.post(
            "/api/v1.0/companies/2/customers/",
            json={"CompanyName": "Job Customer"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        cust_id = cust_resp.json()["ID"]
        # POST a new job referencing that customer
        resp = client.post(
            "/api/v1.0/companies/2/jobs/",
            json={"CustomerId": cust_id, "Description": "Fix plumbing"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 201
        location = resp.headers["Location"]
        # PATCH the job's description
        patch_resp = client.patch(
            location,
            json={"Description": "Fix plumbing urgently"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert patch_resp.status_code == 204
        # GET and confirm the update
        get_resp = client.get(
            location,
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert get_resp.status_code == 200

And here is the quote round-trip test class. Add it below TestJobWriteCompany2:

class TestQuoteWriteCompany1:
    """POST-PATCH-GET round-trip for quotes under company 1."""

    def test_create_update_read_quote(self, client):
        # Create a customer under company 1 for the FK
        cust_resp = client.post(
            "/api/v1.0/companies/1/customers/",
            json={"CompanyName": "Quote Customer"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        cust_id = cust_resp.json()["ID"]
        # POST a new quote
        resp = client.post(
            "/api/v1.0/companies/1/quotes/",
            json={"CustomerId": cust_id, "Description": "New roof"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 201
        location = resp.headers["Location"]
        # PATCH the quote's status
        patch_resp = client.patch(
            location,
            json={"Status": "Approved"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert patch_resp.status_code == 204
        # GET and confirm the update
        get_resp = client.get(
            location,
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert get_resp.status_code == 200
  • Save tests/test_simpro_mock.py.
  • Confirm all three round-trip tests pass by running:
docker compose exec simpro-mock pytest tests/ -v -k "CustomerWrite or JobWrite or QuoteWrite"

You should see 3 passed: one each for customers, jobs, and quotes.

Write cross-company isolation tests

Round-trip tests prove writes work. Isolation tests prove they only work under the right company. These are negative tests: every assertion expects a 404.

  • In tests/test_simpro_mock.py, add the cross-company isolation test class below your round-trip tests:
class TestCrossCompanyIsolation:
    """Cross-company write isolation negative tests."""

    def test_cross_company_get_and_patch_404(self, client):
        # Create a customer under company 1
        resp = client.post(
            "/api/v1.0/companies/1/customers/",
            json={"CompanyName": "Isolated Corp"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        location = resp.headers["Location"]
        # Swap company 1 for company 2 in the URL
        cross_url = location.replace("/companies/1/", "/companies/2/")
        # GET via company 2 must return 404
        get_resp = client.get(
            cross_url,
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert get_resp.status_code == 404
        # PATCH via company 2 must also return 404
        patch_resp = client.patch(
            cross_url,
            json={"CompanyName": "Should Fail"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert patch_resp.status_code == 404

What does the URL-swap technique prove?

The test creates a resource under company 1, then replaces /companies/1/ with /companies/2/ in the Location header. The compound WHERE id = :id AND company_id = :company_id in the route handler will find no match, so it returns 404 without revealing whether the resource exists.

The FK constraint alone only proves a customer row exists somewhere in the database. It does not prove the customer belongs to the company the resource is being created under. The next method tests this.

  • Still inside the TestCrossCompanyIsolation class, add this method below test_cross_company_get_and_patch_404:
    def test_cross_company_customer_id_on_quote(self, client):
        """POST a quote to company 1 referencing company 2's customer."""
        # Create a customer under company 2
        cust_resp = client.post(
            "/api/v1.0/companies/2/customers/",
            json={"CompanyName": "Company 2 Customer"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        company2_cust_id = cust_resp.json()["ID"]
        # Try to create a quote under company 1 using that customer_id
        resp = client.post(
            "/api/v1.0/companies/1/quotes/",
            json={"CustomerId": company2_cust_id, "Description": "Cross ref"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 404

Why is this a 404 and not a 400?

The route handler uses the same compound-WHERE pattern: WHERE id = :customer_id AND company_id = :company_id. When the customer belongs to a different company, the lookup returns no row. The handler treats this as "customer not found in this company" and returns 404, without leaking whether the customer exists elsewhere.

  • Save tests/test_simpro_mock.py.
  • Confirm the isolation tests pass by running:
docker compose exec simpro-mock pytest tests/ -v -k TestCrossCompanyIsolation

You should see 2 passed: one for the GET/PATCH cross-company test and one for the cross-company customer_id test.

Cross-company test returning 200 instead of 404?

  • Check that your PATCH route handler in routers.py uses the compound WHERE clause with both id and company_id. If you query only by id, the resource is found regardless of which company URL the request came through.
  • Check that _get_customer_in_company_or_404 uses the URL's company_id parameter, not a value from the request body.

Help me debug why my cross-company isolation test returns 200 instead of 404.

Write validation edge cases and a sequence-collision regression test

Three edge cases remain. A POST with missing required fields should return 422. An empty PATCH body should return 422 (via the exclude_unset emptiness check, not a None-check). And a freshly-POSTed resource's auto-assigned ID must not collide with seeded rows.

  • In tests/test_simpro_mock.py, add the validation edge-case test class below TestCrossCompanyIsolation:
class TestWriteValidationEdgeCases:
    """Validation edge cases for POST and PATCH."""

    def test_post_missing_required_fields_422(self, client):
        # POST customer with empty body - CompanyName is required
        resp = client.post(
            "/api/v1.0/companies/1/customers/",
            json={},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 422

    def test_patch_empty_body_422(self, client):
        # PATCH with truly empty body triggers model_validator
        resp = client.patch(
            "/api/v1.0/companies/1/customers/1",
            json={},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 422

    def test_sequence_no_collision_after_seed(self, client):
        # POST and let the DB assign the next ID via sequence
        resp = client.post(
            "/api/v1.0/companies/1/customers/",
            json={"CompanyName": "Sequence Test Corp"},
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert resp.status_code == 201
        new_id = resp.json()["ID"]
        # Verify the resource is accessible (no PK collision)
        get_resp = client.get(
            f"/api/v1.0/companies/1/customers/{new_id}",
            headers={"Authorization": "Bearer static-dev-token"},
        )
        assert get_resp.status_code == 200

What does this code do?

  • test_post_missing_required_fields_422 sends an empty JSON body to the customer POST endpoint. Pydantic rejects it because company_name is a required field on CustomerCreate.
  • test_patch_empty_body_422 sends {} to the PATCH endpoint. The model_validator checks model_dump(exclude_unset=True) and finds an empty dict, so it raises a validation error.
  • test_sequence_no_collision_after_seed is a regression test for the sequence-sync fix in seed.py. If the fix is missing, nextval() returns an ID already taken by a seeded row, and the POST fails with a primary key collision.
  • Save tests/test_simpro_mock.py.

Before you run the full suite, predict: how many total Sprint 3 tests do you expect to pass? Count the round-trip tests, the isolation tests, and the edge-case tests.

  • Run the full test suite by running this command:
docker compose exec simpro-mock pytest tests/ -v

You should see all new Sprint 3 tests pass alongside the existing Sprint 2 tests. The Sprint 3 additions include 3 round-trip tests, 2 cross-company isolation tests, and 3 validation edge-case tests.

Seeing 422 on the sequence-collision test?

  • This can happen if the setval call in seed.py runs before the seed inserts instead of after. Confirm the sequence-sync block appears after all seed inserts, before the final db.commit().
  • Restart the containers with docker compose down && docker compose up -d to re-seed the database and re-sync sequences, then run the tests again.

Help me debug a sequence-collision test failure.

✔️ Awesome, I've got everything!

Great. Double check you have saved tests/test_simpro_mock.py.

ⓧ I'd like to double check the full code

Scroll through your tests/test_simpro_mock.py and confirm each of these Sprint 3 test classes is present below your existing Sprint 2 tests, in order:

  • TestCustomerWriteCompany1 with test_create_update_read_customer.
  • TestJobWriteCompany2 with test_create_update_read_job.
  • TestQuoteWriteCompany1 with test_create_update_read_quote.
  • TestCrossCompanyIsolation with test_cross_company_get_and_patch_404 and test_cross_company_customer_id_on_quote.
  • TestWriteValidationEdgeCases with test_post_missing_required_fields_422, test_patch_empty_body_422, and test_sequence_no_collision_after_seed.

Your write-path test coverage is solid. You have proof that POST-PATCH-GET round-trips work under both companies, that cross-company access is blocked on reads and writes, that cross-company FK references are rejected, and that sequence IDs don't collide with seeded rows. Next up, you'll update the ADR and documentation to match what the codebase now does.

Update Documentation and ADR

Your test suite now proves that every write operation respects company boundaries. The code is functionally complete and verified.

The ADR still lists write operations as out-of-scope. Shipping code that contradicts the documentation creates a gap between what the system does and what the docs say it does. This step closes that gap and commits the entire sprint.

In this step, get ready to:
  • Update the ADR to document write operation capabilities and design decisions.
  • Add new endpoint entries to the configuration reference.
  • Commit all Sprint 3 changes to the develop branch.
Update the ADR with write operation capabilities

The ADR currently lists write operations under "Out of Scope and Limitations." That was accurate before this sprint, but now the mock handles POST and PATCH with full company scoping.

  • Open docs/ADR/adr-mock-simpro-api.md in your editor.
  • Find the write operations bullet under the Out of Scope and Limitations section.
  • Remove that bullet from "Out of Scope."
  • Under the Capabilities and Coverage section, add the following content:
### Write Operations (POST, PATCH)

- **POST** returns `201 Created` with a `Location` header pointing at the
  new resource and the created resource in the response body.
- **PATCH** returns `204 No Content`. Partial updates use `exclude_unset`
  semantics to distinguish omitted fields from explicitly null fields.
- Both enforce `company_id` scoping via the URL path parameter, never
  from the request body. Request models structurally exclude `company_id`
  (`extra="ignore"` drops body-injected fields like `CompanyId`).
- Cross-company writes return `404`. The lookup query filters on both
  `id` and `company_id` in a single WHERE clause.
- `customer_id` on Job/Quote creates is company-scoped via a compound
  WHERE lookup. The FK constraint alone only proves the customer row
  exists somewhere, not that it belongs to the correct company.

What does this section capture?

Each bullet documents a design decision, not just a feature. The company_id sourcing rule (URL path, never request body) and the compound WHERE on customer_id are the two decisions most likely to be questioned later.

Recording them here means the next developer who asks "why doesn't the body accept CompanyId?" finds the answer in the ADR instead of reverse-engineering extra="ignore" from the Pydantic config.

The ADR also needs to document why POST and GET behave differently for nonexistent companies. This is a deliberate asymmetry that would look like a bug without explanation.

  • Below the write operations section you just added, add this subsection:
#### POST-vs-GET 404 Asymmetry

POST to a nonexistent company returns `404` (via `get_company_or_404`).
GET list endpoints for a nonexistent company return an empty list
(Sprint 2 behaviour).

**Rationale:** A GET list returning empty is a valid response (the company
might exist but have no resources). A POST silently creating resources
under a nonexistent company is a data integrity failure.

Why document this asymmetry?

Without this note, a future developer might "fix" the inconsistency by making GET list also return 404 for nonexistent companies. That would break existing client code that relies on empty-list responses. The rationale stops the well-intentioned fix before it ships.

  • Save docs/ADR/adr-mock-simpro-api.md.
  • Confirm that "Write operations (POST, PATCH)" now appears under Capabilities and Coverage (not under "Out of Scope"), and the POST-vs-GET asymmetry rationale is visible below it.
Add new endpoints to the configuration reference
  • Open services/simpro_mock/configuration.md in your editor.
  • Find the existing endpoint/route table.
  • Add these six rows to the table:
| POST   | `/api/v1.0/companies/{company_id}/customers/`              | Create a customer | 201 + Location |
| POST   | `/api/v1.0/companies/{company_id}/jobs/`                   | Create a job      | 201 + Location |
| POST   | `/api/v1.0/companies/{company_id}/quotes/`                 | Create a quote    | 201 + Location |
| PATCH  | `/api/v1.0/companies/{company_id}/customers/{customer_id}` | Update a customer | 204 No Content |
| PATCH  | `/api/v1.0/companies/{company_id}/jobs/{job_id}`           | Update a job      | 204 No Content |
| PATCH  | `/api/v1.0/companies/{company_id}/quotes/{quote_id}`       | Update a quote    | 204 No Content |

What about new environment variables?

This sprint introduced no new environment variables. The write endpoints reuse the same database connection, auth middleware, and configuration that the existing GET endpoints use. No changes to the env var section are needed.

  • Save services/simpro_mock/configuration.md.
  • Confirm the table now lists all six new routes alongside the existing GET endpoints.
Commit all Sprint 3 changes to the develop branch

Every file changed in this sprint needs to be staged and committed together: the request models, route handlers, seed fix, tests, and documentation updates.

  • Stage and commit all Sprint 3 changes by running these commands:
git add .
git commit -m "feat(simpro-mock): add POST/PATCH endpoints with multi-company write isolation (Phase 3 Sprint 3)"

What does this commit message convey?

The feat(simpro-mock): prefix follows the Conventional Commits format. It signals a feature addition scoped to the simpro-mock service. The body names the two HTTP methods and the key guarantee (multi-company write isolation) so the commit is searchable without opening the diff.

Before you check the log, what do you expect to see as the most recent commit message?

  • Verify the commit landed by running:
git log --oneline -1

You should see your commit hash followed by feat(simpro-mock): add POST/PATCH endpoints with multi-company write isolation (Phase 3 Sprint 3).

Commit failed or shows unexpected files?

If git add . staged files you did not intend (like .pyc or __pycache__), check that your .gitignore includes Python bytecode patterns. You can unstage with git reset HEAD, fix the ignore file, then re-stage and commit.

If you see "nothing to commit, working tree clean," your changes were already committed in a prior step. Run git log --oneline -3 to check recent history.

Help me troubleshoot my git commit for Sprint 3.

Secret mission

Validation Hardening and Body-Injection Defense

A subtle client bug could send CompanyId in the POST body, attempting to hijack which company a resource belongs to. Your existing write endpoints have a structural defense against this. In this mission, you prove it works by writing tests that attempt body injection and verify it fails silently.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker containers and local files, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker containers: simpro-mock service and simpro-mock-db PostgreSQL container.
  • Docker volumes: PostgreSQL data volume persisting seed data and any resources created during testing.
  • Local project files: Sprint 3 code changes across schemas.py, routers.py, seed.py, test_simpro_mock.py, and documentation files committed to the develop branch.

Keep everything running

No action needed. Choose this if you are continuing into the next sprint or actively developing against the mock API.

  • Your Docker containers remain running and the mock API stays available at http://localhost:8100.
  • Your Git history on the develop branch preserves all Sprint 3 changes.
  • The PostgreSQL container retains seed data and any resources you created via POST during testing.

Pause - I'll come back to this later

Stop the running containers to free up memory and CPU, but keep your data volumes and project files intact for later.

  • Stop the containers by running this command from your project directory:
docker compose stop
  • When you are ready to resume, start everything back up by running:
docker compose up -d
  • Your database data and all local project files remain untouched.

Delete - I don't want to use this again

Remove all Docker containers, volumes, and project files to start completely fresh.

  • Stop and remove the Docker containers along with their data volumes by running this command from your project directory:
docker compose down -v
  • This removes the simpro-mock and simpro-mock-db containers, the PostgreSQL data volume, and the Docker network.
  • If you also want to remove the local project files, navigate to the parent directory and delete the project folder.

On macOS/Linux:

cd ..
rm -rf simpro_mock

On Windows:

cd ..
rmdir /s /q simpro_mock
  • Prune unused Docker images to reclaim disk space by running:
docker image prune -f

Nice Work!

Nice Work!

Nice work! You just turned a read-only mock API into a fully write-capable one with company-scoped isolation on every operation. Your FastAPI service now handles creates and updates with the same multi-tenant guarantees as reads, proven by automated tests that exercise both the happy path and every cross-company attack vector.

You've learned how to:

  • Build POST and PATCH endpoints that enforce company-scoping from the URL path, return proper HTTP status codes (201 Created with a Location header, 204 No Content), and reject cross-company modifications with 404.
  • Design Pydantic request models with PascalCase aliases, exclude_unset-based partial update semantics, and structural body-injection defense via extra="ignore" that silently drops fields like CompanyId from the request body.
  • Write multi-company isolation tests that prove cross-company writes, cross-company customer_id references, and PostgreSQL sequence collisions are all caught and prevented.
  • Secret Mission: Proved that body-injection defense is structural (the company_id field doesn't exist on the model) and confirmed exclude_unset correctly distinguishes empty PATCH bodies from intentional null-clears.

Ready to quiz yourself?