# Functional Tests -- TRD

## Status

**Deferred** -- 2026-03-22 (infrastructure described here is not yet implemented)

## Overview

ows-coda is an AI agent that orchestrates multiple services: AWS Bedrock for LLM inference, Redis for conversation caching and rate limiting, MySQL for persistent storage, and downstream APIs (Snowflake, ows-abacus-account, ows-royalties, etc.) for domain data retrieval. A single user request can trigger a multi-round streaming loop where the orchestrator calls the provider, receives tool-use instructions, executes tools against live backends, feeds results back to the provider, and streams SSE events to the client -- all while managing conversation history across two storage layers.

Unit tests verify individual functions in isolation. Manual testing against staging verifies the deployed system. Between these two layers is a gap where the most dangerous bugs hide: middleware ordering errors, cache-DB consistency failures, SSE serialization bugs, auth bypass leaks, orchestrator loop edge cases, and race conditions in the fire-and-forget persistence path. These are integration-level defects that only surface when real components interact, but they are invisible to unit tests because the mocks assume correct wiring.

Functional tests close this gap. They exercise the full server stack -- routes, middleware, orchestrator, cache, and database -- with real MySQL and Redis, replacing only the AI provider with a deterministic fake. This gives us confidence that the system works end-to-end without depending on AWS Bedrock credentials, network availability, or nondeterministic LLM output.

The test suite covers 43 cases organized across three files: conversation lifecycle (CRUD, streaming, pagination, identity isolation, DB fallback), tool execution (single-tool, multi-tool, reasoning, error handling, max-rounds safeguard), and edge cases (validation, auth, degradation, health endpoints, rate limiting).

## Goals

1. **Catch integration bugs before production.** Verify that routes, middleware, orchestrator, cache, and DB work together correctly -- the exact seam that unit tests cannot cover.

2. **Deterministic AI behavior.** Replace the nondeterministic LLM with a `FakeProvider` that returns canned responses from a queue, making tests reproducible and fast.

3. **Zero new infrastructure.** Run on the existing Docker Compose `test-functional` profile (MySQL 8 + Redis 7 + Prisma migrations) via `pnpm docker:test:functional`. No new services, no new CI pipelines.

4. **Cover the orchestrator loop.** Validate multi-round tool use, parallel tool execution, auto-batch patterns, reasoning event streaming, error recovery, and the 15-round max safeguard.

5. **Verify identity isolation.** Confirm that user A cannot read, list, or delete user B's conversations -- a correctness property that only emerges from the interaction of auth middleware, cache key scoping, and DB queries.

6. **Validate graceful degradation.** Confirm the system behaves correctly when Redis is unavailable, when the DB is unavailable, when the provider errors mid-stream, and when the provider returns empty responses.

7. **Low maintenance cost.** Tests depend on stable public interfaces (HTTP routes, SSE wire format, `AIProvider` contract) rather than internal implementation details, so they do not break on refactors.

## Architecture

### Test boundary

The functional tests exercise everything inside the Express server process. The only component replaced is the AI provider, which is swapped for a deterministic `FakeProvider` via Jest module mocking. Auth0 JWT validation is bypassed with a configurable test identity. MySQL and Redis run as real Docker containers.

```
                        Test boundary
                        |
  supertest ----------->|---> Express app
                        |      |
                        |      +---> requireAuth (bypassed, injects test identity)
                        |      |
                        |      +---> route handlers
                        |      |
                        |      +---> orchestrator (streaming loop)
                        |      |       |
                        |      |       +---> FakeProvider (queue-based, deterministic)
                        |      |       |       replaces: AWS Bedrock
                        |      |       |
                        |      |       +---> tool handlers (real, but Snowflake unavailable)
                        |      |
                        |      +---> cache layer ---> Redis (real, Docker)
                        |      |
                        |      +---> DB layer ------> MySQL (real, Docker)
                        |
```

### Infrastructure

The Docker Compose `test-functional` profile provides:

| Service         | Image             | Role                                        |
| --------------- | ----------------- | ------------------------------------------- |
| mysql           | mysql:8.0.36      | Persistent storage (chats, messages, users) |
| redis           | redis:7-alpine    | Conversation cache, rate limiting           |
| migrate         | Prisma migrations | Schema setup before tests run               |
| test-functional | Node + Jest       | Test runner (depends on migrate + redis)    |

The `test-functional` container depends on `migrate` (completed successfully) and `redis` (healthy), ensuring the database schema and cache are ready before any test executes.

Command: `pnpm docker:test:functional`

Equivalent: `docker compose --project-name coda --profile test-functional run --build --rm test-functional`

### File structure

```
server/src/__tests__/functional/
  helpers/
    test-harness.ts                  # createTestApp(), cleanup(), teardown()
    fake-provider.ts                 # FakeProvider implementing AIProvider
    auth-bypass.ts                   # requireAuth patch + setTestIdentity()
    sse-parser.ts                    # Parse SSE response buffer into structured events
    __tests__/
      sse-parser.test.ts             # Unit tests for the SSE parser itself
  conversation-lifecycle.test.ts     # 22 tests: CRUD, streaming, pagination, isolation, fallback
  tool-execution.test.ts             # 7 tests: single/multi tool, reasoning, errors, max rounds
  edge-cases.test.ts                 # 14 tests: validation, auth, degradation, health, rate limiting
```

## Detailed Design

### FakeProvider

The `FakeProvider` implements the `AIProvider` interface with two FIFO queues: one for `streamRound()` (returns `ProviderStreamEvent[]` sequences) and one for `converse()` (returns `ConverseResult` for auto-naming and suggestion generation). Every call is recorded for assertion.

Key properties:

- **Deterministic.** Responses are pre-enqueued; no randomness, no network calls.
- **Fail-fast.** If a queue is empty when a method is called, it throws immediately with a descriptive error rather than returning undefined or hanging.
- **Full interface compliance.** Implements all `AIProvider` methods including `initMessages()`, `buildAssistantMessage()`, `buildToolResultMessage()`, `supportsThinking()`, `resolveModelId()`, `getModelInfo()`, `warmUp()`, and `isReady()`.

Static factory helpers produce common event sequences:

| Helper                                    | Produces                                                                  |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| `FakeProvider.textEvents(text)`           | `[textDelta, blockStop, messageStop("end_turn"), usage]`                  |
| `FakeProvider.toolUseEvents(name, input)` | `[toolUseStart, toolUseDelta, blockStop, messageStop("tool_use"), usage]` |
| `FakeProvider.reasoningEvents(text)`      | `[reasoningDelta, blockStop]`                                             |
| `FakeProvider.errorEvents(msg)`           | `[{ type: "error", message }]`                                            |
| `FakeProvider.converseResult(text)`       | `{ text }` (for auto-naming / suggestions)                                |

The provider uses `ProviderType.Bedrock` as its type. The registry mock routes all lookups (`getProvider()`, `getDefaultProvider()`, `getAllProviders()`) to the single `FakeProvider` instance, so the orchestrator, route handlers, and health checks all interact with the fake.

### Test harness

The harness (`test-harness.ts`) orchestrates test lifecycle:

- **`createTestApp()`** -- Installs the auth bypass, sets up the provider registry mock (via `jest.mock()` hoisted before `createServer()` is dynamically imported), creates the Express app, and establishes a dedicated Redis client for cleanup. Called once in `beforeAll`.

- **`cleanup()`** -- Flushes Redis (`FLUSHDB`), truncates all MySQL tables (with `FOREIGN_KEY_CHECKS = 0` for speed, skipping `_prisma_migrations`), resets the auth identity to defaults, and clears `FakeProvider` queues. Called in `afterEach`.

- **`teardown()`** -- Disconnects the cleanup Redis client, the cache store, and the Prisma client. Called in `afterAll`.

Per-test-file setup pattern:

```typescript
let app: Application;

beforeAll(async () => {
  ({ app } = await createTestApp());
});

afterEach(async () => {
  await cleanup();
});

afterAll(async () => {
  await teardown();
});
```

### Auth bypass

The auth bypass replaces the `requireAuth` middleware export with a pass-through that populates `res.locals` with a configurable test identity (default: `identityId: "test-user-1"`, `roles: "administrator"`, `givenName: "Test"`).

The bypass is toggleable: `disableBypass()` makes `requireAuth` return 401, enabling tests that verify unauthenticated request handling. `enableBypass()` restores normal bypass behavior.

`setTestIdentity({ identityId: "test-user-2" })` switches the identity for subsequent requests, enabling identity isolation tests without restarting the app.

### SSE parser

The SSE parser converts a raw response body string into a structured `ParsedSSE` object:

```typescript
interface ParsedSSE {
  chunks: string[]; // text from unnamed data lines
  events: Record<string, object[]>; // named events by type
  done: boolean; // whether {done: true} received
  error: { error: string; message: string } | null; // error frame if present
}
```

It handles both wire formats from `sse-utils.ts`:

- Unnamed: `data: {"chunk":"text"}\n\n`
- Named: `event: <type>\ndata: <json>\n\n`

Supported named event types: `thinking`, `reasoning`, `selection_required`, `clear_partial`, `sources`, `suggestions`, `attachments`, `usage`, `title`, `warnings`.

### Test cases (43 total)

#### conversation-lifecycle.test.ts (22 tests)

**CRUD (7 tests)**

| #   | Test                       | Key assertions                                            |
| --- | -------------------------- | --------------------------------------------------------- |
| 1   | Create conversation        | 201, response has `id`, `title`, `createdAt`, `updatedAt` |
| 2   | Rename chat                | 200, updated title; subsequent GET reflects change        |
| 3   | Star chat                  | 200, `starred: true`                                      |
| 4   | Unstar chat                | 200, `starred: false`                                     |
| 5   | Stale `updatedAt` conflict | 409 (TOCTOU detection)                                    |
| 6   | Soft-delete chat           | Chat absent from main list                                |
| 7   | Bulk-delete                | Returns deleted count, all removed                        |

**Streaming (5 tests)**

| #   | Test                              | Key assertions                                                                  |
| --- | --------------------------------- | ------------------------------------------------------------------------------- |
| 8   | Text response stream              | SSE has text chunks, usage event, done frame                                    |
| 9   | Messages persisted after stream   | GET messages returns user + assistant; asserts cache path (DB persist is async) |
| 10  | Auto-naming on first exchange     | `converse()` called for naming; SSE has `title` event                           |
| 11  | Multi-turn history preservation   | Second `streamRound` receives 3 messages (user1, assistant1, user2)             |
| 12  | Attachments forwarded to provider | Provider receives attachment content blocks                                     |

**Pagination (5 tests)**

| #   | Test               | Key assertions                                          |
| --- | ------------------ | ------------------------------------------------------- |
| 13  | Limit parameter    | Returns at most `limit` chats, `hasNextPage` is true    |
| 14  | Forward cursor     | `after` cursor returns next page with different IDs     |
| 15  | Backward cursor    | `before` cursor returns previous page matching original |
| 16  | Starred sort order | Starred chats appear first regardless of `updatedAt`    |
| 17  | ETag / 304         | Second identical GET returns 304 Not Modified           |

**Message history (1 test)**

| #   | Test              | Key assertions                                           |
| --- | ----------------- | -------------------------------------------------------- |
| 18  | Messages in order | Cursor pagination works; messages match streamed content |

**Identity isolation (3 tests)**

| #   | Test                         | Key assertions                                |
| --- | ---------------------------- | --------------------------------------------- |
| 19  | Cross-user list isolation    | User B sees empty list for User A's chats     |
| 20  | Cross-user message isolation | User B gets 404 for User A's messages         |
| 21  | Cross-user delete isolation  | User B's delete does not affect User A's chat |

**DB fallback (1 test)**

| #   | Test                     | Key assertions                                                            |
| --- | ------------------------ | ------------------------------------------------------------------------- |
| 22  | Cache miss loads from DB | Flush Redis after persist; GET messages returns DB data; Redis backfilled |

#### tool-execution.test.ts (7 tests)

| #   | Test                            | Key assertions                                                |
| --- | ------------------------------- | ------------------------------------------------------------- |
| 23  | Single tool round-trip          | Provider returns tool_use, then text; two `streamRound` calls |
| 24  | Tool returns source links       | SSE contains `sources` event                                  |
| 25  | Parallel tool execution         | Two tool_use blocks in one round; both execute                |
| 26  | Auto-batch (product to account) | Product tool triggers auto-batch account lookup               |
| 27  | Reasoning events streamed       | SSE contains `reasoning` events                               |
| 28  | Tool handler throws             | Error result fed to provider; no server crash                 |
| 29  | Max rounds safeguard (15)       | Orchestrator stops after 15 rounds; stream ends cleanly       |

#### edge-cases.test.ts (14 tests)

| #   | Test                                 | Key assertions                         |
| --- | ------------------------------------ | -------------------------------------- |
| 30  | Missing query                        | 400                                    |
| 31  | Query exceeds MAX_QUERY_LENGTH       | 400                                    |
| 32  | Too many attachments                 | 400                                    |
| 33  | Invalid UUID                         | 400                                    |
| 34  | No auth headers                      | 401 (bypass disabled)                  |
| 35  | Redis unavailable -- streaming works | Stream succeeds with empty history     |
| 36  | Redis unavailable -- CRUD degrades   | Errors or degradation handled          |
| 37  | DB unavailable, Redis available      | CRUD works via cache                   |
| 38  | Provider error mid-stream            | SSE contains error event               |
| 39  | Provider returns empty response      | `messageStop` only; handled gracefully |
| 40  | GET /health                          | 200, `{ status: "ok" }`                |
| 41  | GET /health/ready                    | 200 when provider ready                |
| 42  | GET /models                          | Returns model list (auth required)     |
| 43  | Rate limiting                        | 11 rapid requests; last returns 429    |

## Alternatives Explored

### Why fake at the provider level instead of mocking at HTTP?

Mocking at the HTTP layer (intercepting Bedrock API calls with nock or msw) would require replicating the Bedrock Converse API wire format, which is complex (chunked streaming, binary event encoding) and couples tests to AWS SDK internals. Mocking at the `AIProvider` interface is cleaner: the interface is stable, owned by this project, and already abstracts over provider differences. The orchestrator, message building, and tool result formatting all run for real, which is exactly what we want to test.

### Why not end-to-end tests with real Bedrock?

Real Bedrock calls are slow (2-10s per round), nondeterministic (different outputs for identical inputs), expensive ($0.003-0.015 per request), and require AWS credentials in CI. Functional tests need to be fast, deterministic, and free. The `FakeProvider` approach gives us full control over the provider's behavior (including error cases and edge cases that are hard to trigger against real Bedrock) while still exercising the entire server stack.

### Why Docker for test infrastructure instead of in-memory fakes?

SQLite does not support MySQL-specific features used by the Prisma schema (e.g., `@db.Text`, JSON columns, `FOREIGN_KEY_CHECKS`). An in-memory Redis fake would not exercise the real ioredis client, connection handling, or pipeline behavior. Docker Compose provides exact parity with production infrastructure (MySQL 8, Redis 7) and the `test-functional` profile is already defined with health checks and migration sequencing.

### Why supertest instead of a running server?

supertest wraps the Express app directly, avoiding the need to start a server process, manage ports, or handle lifecycle. This makes tests faster to start, eliminates port conflicts, and keeps the test boundary tight. The trade-off is that supertest does not exercise the HTTP server's listen/close lifecycle, but that is covered by integration tests in the `test-integration` profile.

## Cost Analysis

### CI time

| Component                 | Time estimate | Notes                                                                      |
| ------------------------- | ------------- | -------------------------------------------------------------------------- |
| Docker image build        | ~60s          | Cached after first build; shared base with other test profiles             |
| MySQL + Redis startup     | ~10s          | Health checks gate test start                                              |
| Prisma migrations         | ~5s           | Runs against empty DB                                                      |
| Test execution (43 tests) | ~30-60s       | Most tests complete in <1s; rate limiting and max-rounds tests take longer |
| **Total**                 | **~2 min**    | Added to CI pipeline as a parallel job                                     |

### Engineering effort

| Task                                                                 | Estimate     |
| -------------------------------------------------------------------- | ------------ |
| Test infrastructure (harness, FakeProvider, auth bypass, SSE parser) | 1-2 days     |
| Conversation lifecycle tests (22 tests)                              | 1 day        |
| Tool execution tests (7 tests)                                       | 0.5 day      |
| Edge case tests (14 tests)                                           | 0.5 day      |
| Docker integration and CI validation                                 | 0.5 day      |
| **Total**                                                            | **3-4 days** |

### ROI

The test suite catches classes of bugs that have historically required manual testing or been discovered in staging/production:

- SSE serialization errors (malformed events, missing done frames)
- Cache-DB consistency failures (stale reads after Redis flush, missing DB backfill)
- Auth bypass leaks (identity isolation violations)
- Orchestrator loop bugs (infinite tool-use loops, missing max-rounds guard)
- Validation gaps (missing Zod schema enforcement on new fields)

Each of these categories has produced at least one production incident or near-miss. A 3-4 day investment that prevents even one such incident pays for itself.

## Performance Analysis

### Test execution time

- **Individual test:** Most tests complete in 100-500ms. Supertest against an in-process Express app avoids network overhead.
- **Rate limiting test:** Takes ~5-10s due to 11 sequential requests.
- **Max rounds test:** Takes ~5-15s due to 15+ orchestrator rounds. Has a 30s Jest timeout.
- **Full suite:** 30-60s for 43 tests running serially within a single Jest worker.

### Serial vs parallel execution

Tests run serially within each file (Jest default for `--runInBand` or single-worker mode). Parallelism across files is possible but introduces complexity around shared MySQL/Redis state. The current approach uses `afterEach` cleanup (FLUSHDB + TRUNCATE) for isolation within a file. Cross-file parallelism would require per-file database isolation (separate schemas or transaction rollback), which is deferred until the suite grows large enough to justify it.

### DB reset strategy

The cleanup function uses `TRUNCATE TABLE` with `FOREIGN_KEY_CHECKS = 0` rather than `DELETE FROM` or transaction rollback. This is the fastest approach for full table reset and avoids auto-increment drift. The `_prisma_migrations` table is excluded to preserve migration state.

Redis cleanup uses `FLUSHDB` on a dedicated client (separate from the app's cache client) to avoid interfering with in-flight operations.

## Scaling Characteristics

### Adding new test cases

New tests are added to the appropriate file (`conversation-lifecycle`, `tool-execution`, or `edge-cases`) following the existing pattern: enqueue FakeProvider responses, make an HTTP request via supertest, assert on the response and/or FakeProvider call log. No harness changes needed for most new tests.

### Adding new test categories

New test files (e.g., `attachment-handling.test.ts`, `admin-endpoints.test.ts`) import from the shared helpers and follow the same `beforeAll`/`afterEach`/`afterAll` lifecycle. The `docker:test:functional` command picks up any file matching `src/__tests__/functional/**/*.test.ts` automatically.

### CI parallelism

If the suite exceeds ~2 min, it can be split into parallel CI jobs per test file. Each job would run the same Docker Compose profile but with a `--testPathPattern` filter. MySQL and Redis containers can be shared or duplicated depending on the CI platform's container support.

### New provider types

If additional AI providers are added (e.g., OpenAI, Anthropic direct API), the FakeProvider already covers the `AIProvider` interface. Tests do not need to change unless the interface itself changes, in which case the FakeProvider must be updated to match.

## Breakdown Points & Mitigations

### Flaky tests from nondeterminism

**Risk:** Tests that depend on timing (rate limiting, async DB persist) may flake.

**Mitigation:** The FakeProvider is fully deterministic -- no randomness, no network calls. The rate limiting test uses sequential requests with a known threshold. The DB fallback test includes a 500ms delay for the async persist path, which is conservative. If flakiness occurs, the delay can be replaced with a polling assertion (`waitFor` pattern).

### Slow startup from connection establishment

**Risk:** MySQL and Redis connection setup adds latency to `beforeAll`.

**Mitigation:** Docker Compose health checks ensure MySQL and Redis are ready before the test container starts. The Prisma client connects lazily on first query. The `createTestApp()` function is called once per file, not per test.

### Test isolation failures

**Risk:** A test that crashes mid-execution may leave state in MySQL/Redis that affects subsequent tests.

**Mitigation:** `afterEach` performs a full cleanup (FLUSHDB + TRUNCATE). If a test crashes before `afterEach` runs, the next test's `afterEach` will clean up the previous test's state. The `afterAll` teardown disconnects all clients to prevent connection leaks.

### FakeProvider drift from real AIProvider

**Risk:** The `AIProvider` interface changes but the FakeProvider is not updated, causing tests to pass with a stale fake while production breaks.

**Mitigation:** The FakeProvider `implements AIProvider`, so TypeScript compilation fails if the interface changes. This is enforced at build time, not just test time.

### Snowflake-dependent tools unavailable

**Risk:** Tests cannot exercise Snowflake-backed tools because Snowflake is not configured in the test environment.

**Mitigation:** This is by design. Snowflake-dependent tools report themselves as unavailable via their `enabled()` predicate when `snowflakePool` is undefined. The orchestrator handles this gracefully. Snowflake tool behavior is covered by unit tests with mocked pools.

### Rate limiting test sensitivity

**Risk:** The rate limiting test assumes a specific threshold (10 req/min) that may change.

**Mitigation:** The test fires 11 requests and asserts that at least one returns 429. It does not assert the exact threshold boundary. If the limit changes significantly, the test may need to adjust the request count.

## Decision Log

| Date       | Decision                                            | Rationale                                                                                         |
| ---------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 2026-03-18 | Mock at AIProvider interface, not HTTP layer        | Stable contract owned by this project; avoids coupling to Bedrock wire format                     |
| 2026-03-18 | Use real MySQL + Redis in Docker, not in-memory     | Production parity; MySQL-specific features in Prisma schema; real ioredis behavior                |
| 2026-03-18 | supertest (in-process) over running server          | Faster startup, no port management, tighter test boundary                                         |
| 2026-03-18 | TRUNCATE + FLUSHDB for cleanup, not transactions    | Simpler, faster, no transaction management overhead; works with fire-and-forget async persist     |
| 2026-03-18 | Auth bypass via jest.mock, not middleware injection | Patches before module load; no production code changes needed; toggleable for auth-specific tests |
| 2026-03-18 | 43 tests across 3 files, not one monolithic file    | Separation by concern (lifecycle, tools, edge cases); independent parallelization potential       |
| 2026-03-18 | SSE parser as a test helper, not a shared utility   | Only needed by tests; production code emits SSE, it does not parse it                             |

## Dependencies

### New dependencies

| Package            | Type          | Purpose                                   |
| ------------------ | ------------- | ----------------------------------------- |
| `supertest`        | devDependency | HTTP assertions against Express app       |
| `@types/supertest` | devDependency | TypeScript type definitions for supertest |

### Existing dependencies (no changes)

- **Jest + ts-jest** -- Test runner and TypeScript transform
- **ioredis** -- Redis client (used by cleanup helper)
- **@prisma/client** -- Database client (used by cleanup helper)
- **Docker Compose** -- Container orchestration for test infrastructure
- **@coda/core-api** -- Shared types and schemas (`MAX_QUERY_LENGTH`, `MAX_ATTACHMENTS`, `ModelInfo`)

### Infrastructure

- Docker Compose `test-functional` profile (already defined)
- MySQL 8.0.36 container (already defined)
- Redis 7 container (already defined)
- Prisma migrations (already defined)

No new infrastructure provisioning required.

## Testing Strategy

### How do you test the test infrastructure?

The SSE parser has its own unit tests (`sse-parser.test.ts`, 5 tests) verifying correct parsing of text chunks, named events, done frames, error frames, and mixed content. These run without Docker as standard Jest unit tests.

The FakeProvider is validated implicitly: if it does not correctly implement `AIProvider`, TypeScript compilation fails. If its factory helpers produce incorrect event shapes, the functional tests that consume them will fail with assertion errors that point directly at the mismatch.

The auth bypass is validated by test #34 (unauthenticated request returns 401 when bypass is disabled) and tests #19-21 (identity switching produces correct isolation behavior).

The test harness is validated by the fact that all 43 tests depend on it. If `createTestApp()` fails to set up the app correctly, every test fails immediately in `beforeAll`.

### Verification checklist

1. `pnpm docker:test:functional` runs all 43 tests and reports green.
2. Each test file can run independently (`--testPathPattern` filter).
3. Tests are idempotent -- running the suite twice in a row produces the same result.
4. No test depends on execution order within a file (each starts from a clean state via `afterEach` cleanup).

## Rollout Plan

### Phase 1: Test infrastructure (Tasks 1-5)

Install supertest. Implement FakeProvider, auth bypass, SSE parser, and test harness. Validate that the SSE parser's own unit tests pass. Verify TypeScript compilation of all helpers.

**Exit criteria:** `tsc --noEmit` succeeds. SSE parser tests pass.

### Phase 2: Conversation lifecycle (Tasks 6-8)

Implement CRUD tests (7 tests), streaming tests (5 tests), pagination tests (5 tests), identity isolation tests (3 tests), message history tests (1 test), and DB fallback tests (1 test).

**Exit criteria:** All 22 conversation lifecycle tests pass in Docker.

### Phase 3: Tool execution (Task 9)

Implement single-tool, multi-tool, reasoning, and error handling tests (7 tests).

**Exit criteria:** All 7 tool execution tests pass in Docker.

### Phase 4: Edge cases (Task 10)

Implement validation, auth, degradation, health, and rate limiting tests (14 tests).

**Exit criteria:** All 14 edge case tests pass in Docker.

### Phase 5: Validation (Task 11)

Run the complete suite via `pnpm docker:test:functional`. Fix any cross-file interaction issues. Remove the `.gitkeep` placeholder from the functional test directory.

**Exit criteria:** All 43 tests pass. Suite completes in under 2 minutes.

## Open Questions

1. **Should the DB fallback test use a polling assertion instead of a fixed 500ms delay?** The async persist path (`persistTurnAsync`) is fire-and-forget. A fixed delay is simple but may flake under load. A `waitFor` pattern is more robust but adds complexity.

2. **Should we add a test for the auto-batch pattern (product tool triggers account lookup)?** The plan includes it (test #26), but auto-batch logic may be tightly coupled to specific tool implementations that require Snowflake or downstream API configuration not available in the test environment.

3. **Should rate limiting tests use a separate Redis database to avoid interference with other tests' cleanup?** Currently, `FLUSHDB` in `afterEach` resets the rate limiter's sliding window, which is necessary for test isolation but means the rate limiting test must run its 11 requests within a single test case without any cleanup in between.

4. **Should we add source-link tests (test #24) given that tool handlers in the test environment may not produce source links without real downstream API responses?** The FakeProvider controls the provider's output but not the tool handler's output. Source links are generated by tool handlers, which may need specific response data to produce them.

5. **What is the right Jest timeout for the max-rounds safeguard test?** Currently set to 30s, but 15 rounds of tool execution with real Redis + DB writes may take longer under CI load.
