Browse documentation

Test Specs

RankFlow AI — AI Services Test Specification

- [ ] Router resolves the correct default model for every supported task type (12 tasks).

docs/test-specs/TEST-ai-services.md
On this page

Version: 1.0.0
Date: 2026-06-13
Spec: docs/specs/ai-services.md
Scope: src/server/services/ai/
Providers: Anthropic (Claude), OpenAI (GPT-4o)


1. Multi-LLM Router#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
router.selectModel { task: "gbp_post" } Returns claude-haiku (default mapping) src/__tests__/unit/ai/router.test.ts
router.selectModel.override { task: "gbp_post", model: "claude-sonnet" } Returns claude-sonnet (client override respected) src/__tests__/unit/ai/router.test.ts
router.selectModel.invalid { task: "unknown_task" } Throws RouterError: Unknown task type src/__tests__/unit/ai/router.test.ts
router.buildOptions { task: "gbp_post", prompt: "Hello", maxTokens: 512 } Merged options with defaults (temperature: 0.7, maxTokens: 512) src/__tests__/unit/ai/router.test.ts
router.buildOptions.jsonMode { task: "schema_markup", jsonMode: true } Provider options include response_format: { type: "json_object" } src/__tests__/unit/ai/router.test.ts
router.result.shape Valid generate result Object has text, model, provider, tokensUsed, costUsd, latencyMs src/__tests__/unit/ai/router.test.ts
router.result.latency Start = 1000, End = 2450 latencyMs equals 1450 (number) src/__tests__/unit/ai/router.test.ts

Integration Tests#

Test Setup Action Assertion File
router.generate.happy Mock Anthropic SDK returns text + usage ai.generate({ task: "gbp_post", prompt: "Write a post" }) Result.text non-empty, provider="anthropic", costUsd > 0 src/__tests__/integration/ai/router.test.ts
router.generate.openai Mock OpenAI SDK returns text + usage ai.generate({ task: "citation_description", prompt: "Write 30 descriptions" }) Result.provider="openai", model="gpt-4o-mini" src/__tests__/integration/ai/router.test.ts
router.generate.template DB has practice-specific template ai.generateWithTemplate(practiceId, "directory_profile_article", vars) Template loaded from DB, variables substituted, correct model used src/__tests__/integration/ai/router.test.ts
router.generate.audit Audit logger spy ai.generate({ task: "faq" }) logger.info called with event: "ai_generation", all fields present src/__tests__/integration/ai/router.test.ts
router.generate.trpc Authenticated tRPC caller api.content.generate.mutate({ taskType: "GBP_POST", variables: {} }) Returns content object with status: "PENDING_REVIEW", aiGenerated: true, aiTokensUsed > 0 src/__tests__/integration/ai/router.test.ts

Success Criteria (Binary)#

  • Router resolves the correct default model for every supported task type (12 tasks).
  • Client model override bypasses default mapping and is validated against supported models.
  • Unknown task types throw RouterError with a clear message before any provider call.
  • GenerateResult always contains all 6 required fields with correct types.
  • latencyMs is a positive integer measured from just before the provider call to just after.
  • jsonMode: true forces JSON output for providers that support it; fails loudly for unsupported.
  • tRPC content.generate mutation returns a full content record with AI metadata populated.

Agent Context (Pre-conditions)#

  • Required DB state: PromptTemplate rows for at least one global default and one practice-specific override per task type.
  • Required env vars: ANTHROPIC_API_KEY (mocked), OPENAI_API_KEY (mocked), DATABASE_URL (test DB).
  • Required external mocks: Anthropic generateText and OpenAI generateText mocked via msw or vitest spies.

Verification Commands#

# Run router tests
pnpm test:unit -- src/__tests__/unit/ai/router.test.ts
pnpm test:integration -- src/__tests__/integration/ai/router.test.ts

2. Model Configuration#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
config.lookup modelKey: "claude-sonnet" Returns provider="anthropic", modelId="claude-sonnet-4-20250514", inputCost=3.00, outputCost=15.00, context=200000 src/__tests__/unit/ai/model-config.test.ts
config.lookup.haiku modelKey: "claude-haiku" Returns provider="anthropic", inputCost=0.25, outputCost=1.25 src/__tests__/unit/ai/model-config.test.ts
config.lookup.gpt4o modelKey: "gpt-4o" Returns provider="openai", inputCost=2.50, outputCost=10.00, context=128000 src/__tests__/unit/ai/model-config.test.ts
config.lookup.gpt4omini modelKey: "gpt-4o-mini" Returns provider="openai", inputCost=0.15, outputCost=0.60 src/__tests__/unit/ai/model-config.test.ts
config.lookup.invalid modelKey: "gpt-5" Throws ConfigError: Unsupported model key src/__tests__/unit/ai/model-config.test.ts
config.cost.calc inputTokens=1_000_000, outputTokens=500_000, model="claude-sonnet" costUsd = (1_000_000/1_000_000)*3.00 + (500_000/1_000_000)*15.00 = 10.50 src/__tests__/unit/ai/model-config.test.ts
config.cost.zero inputTokens=0, outputTokens=0 costUsd = 0.00 src/__tests__/unit/ai/model-config.test.ts
config.context.respect maxTokens=4096, model="claude-sonnet" maxTokens capped at model context (200K) or 4096 if smaller; never exceeds context window src/__tests__/unit/ai/model-config.test.ts

Integration Tests#

Test Setup Action Assertion File
config.cost.rounding Live cost calculation with fractional tokens Call calculateCost with 1234 input, 567 output on gpt-4o-mini costUsd = (1234/1_000_000)*0.15 + (567/1_000_000)*0.60 = 0.0005253 rounded to 6 decimal places src/__tests__/integration/ai/model-config.test.ts
config.env.missing ANTHROPIC_API_KEY unset Attempt to create Anthropic provider Initialization throws ConfigError: Missing ANTHROPIC_API_KEY src/__tests__/integration/ai/model-config.test.ts
config.env.missing.openai OPENAI_API_KEY unset Attempt to create OpenAI provider Initialization throws ConfigError: Missing OPENAI_API_KEY src/__tests__/integration/ai/model-config.test.ts

Success Criteria (Binary)#

  • Every supported model key (claude-sonnet, claude-haiku, gpt-4o, gpt-4o-mini) resolves to correct provider, model ID, cost rates, and context window.
  • Unsupported model keys throw ConfigError immediately; no fallback or silent defaulting.
  • Cost calculation uses the exact formula: (inputTokens / 1_000_000) * inputCost + (outputTokens / 1_000_000) * outputCost.
  • Cost output is a non-negative finite number, rounded to 6 decimal places.
  • maxTokens is validated against the model's context window; requests exceeding it are rejected before the provider call.
  • Missing provider API keys cause initialization failures with explicit error messages.

Agent Context (Pre-conditions)#

  • Required DB state: None (pure config lookup).
  • Required env vars: ANTHROPIC_API_KEY and OPENAI_API_KEY present or explicitly unset for negative tests.
  • Required external mocks: None.

Verification Commands#

# Run model config tests
pnpm test:unit -- src/__tests__/unit/ai/model-config.test.ts
pnpm test:integration -- src/__tests__/integration/ai/model-config.test.ts

3. Task-to-Model Mapping#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
mapping.gbp_post task: "gbp_post" model: "claude-haiku" src/__tests__/unit/ai/task-mapping.test.ts
mapping.directory_profile task: "directory_profile_article" model: "claude-sonnet" src/__tests__/unit/ai/task-mapping.test.ts
mapping.faq task: "faq" model: "claude-haiku" src/__tests__/unit/ai/task-mapping.test.ts
mapping.citation task: "citation_description" model: "gpt-4o-mini" src/__tests__/unit/ai/task-mapping.test.ts
mapping.social task: "social_caption" model: "gpt-4o-mini" src/__tests__/unit/ai/task-mapping.test.ts
mapping.review task: "review_reply" model: "claude-haiku" src/__tests__/unit/ai/task-mapping.test.ts
mapping.schema task: "schema_markup" model: "claude-sonnet" src/__tests__/unit/ai/task-mapping.test.ts
mapping.seo_audit task: "seo_audit" model: "claude-sonnet" src/__tests__/unit/ai/task-mapping.test.ts
mapping.meta task: "meta_description" model: "gpt-4o-mini" src/__tests__/unit/ai/task-mapping.test.ts
mapping.evolution task: "content_evolution" model: "claude-sonnet" src/__tests__/unit/ai/task-mapping.test.ts
mapping.report task: "report_summary" model: "claude-sonnet" src/__tests__/unit/ai/task-mapping.test.ts
mapping.keyword task: "keyword_research" model: "gpt-4o" src/__tests__/unit/ai/task-mapping.test.ts
mapping.override.practice Practice-level default set to claude-sonnet for gbp_post model: "claude-sonnet" (practice override wins) src/__tests__/unit/ai/task-mapping.test.ts
mapping.override.client model: "gpt-4o" in GenerateOptions model: "gpt-4o" (client override wins over all) src/__tests__/unit/ai/task-mapping.test.ts

Integration Tests#

Test Setup Action Assertion File
mapping.db.practice DB row: practiceId="prac_123", taskType="gbp_post", modelConfig={"model":"claude-sonnet"} ai.generate({ task: "gbp_post", practiceId: "prac_123" }) Anthropic claude-sonnet invoked, not claude-haiku src/__tests__/integration/ai/task-mapping.test.ts
mapping.db.global No practice-specific row; global default claude-haiku for gbp_post ai.generate({ task: "gbp_post", practiceId: "prac_999" }) Anthropic claude-haiku invoked src/__tests__/integration/ai/task-mapping.test.ts
mapping.db.no_template No global or practice template for gbp_post ai.generate({ task: "gbp_post" }) Falls back to hardcoded prompt; model still claude-haiku src/__tests__/integration/ai/task-mapping.test.ts

Success Criteria (Binary)#

  • All 12 supported task types map to their documented default models exactly.
  • Practice-level template overrides (modelConfig in DB) take precedence over global defaults.
  • Client-level model option in GenerateOptions takes precedence over all DB-level defaults.
  • Missing templates do not break model selection; hardcoded defaults are used.
  • Model selection resolution order is: client override → practice default → global default → hardcoded fallback.

Agent Context (Pre-conditions)#

  • Required DB state: PromptTemplate rows with modelConfig overrides for at least one practice and one global default.
  • Required env vars: DATABASE_URL (test DB).
  • Required external mocks: Provider SDKs stubbed to verify which model is actually called.

Verification Commands#

# Run task mapping tests
pnpm test:unit -- src/__tests__/unit/ai/task-mapping.test.ts
pnpm test:integration -- src/__tests__/integration/ai/task-mapping.test.ts

4. Prompt Template System#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
template.resolve.practice practiceId="prac_123", taskType="gbp_post", isDefault=true Returns practice-specific template with highest version src/__tests__/unit/ai/templates.test.ts
template.resolve.global practiceId=null, taskType="gbp_post", isDefault=true Returns global default template src/__tests__/unit/ai/templates.test.ts
template.resolve.hardcoded No DB row for taskType="unknown" Returns hardcoded fallback prompt object src/__tests__/unit/ai/templates.test.ts
template.substitute.single Template: "Hello {{name}}", vars: { name: "World" } "Hello World" src/__tests__/unit/ai/templates.test.ts
template.substitute.multiple Template: "{{city}} {{practiceName}}", vars: { city: "Kochi", practiceName: "Dr. Smith" } "Kochi Dr. Smith" src/__tests__/unit/ai/templates.test.ts
template.substitute.missing Template: "{{city}}", vars: {} Leaves "{{city}}" in place (or throws TemplateError if strict) src/__tests__/unit/ai/templates.test.ts
template.substitute.escaped Template: "{{practiceName}}", vars: { practiceName: "<script>alert(1)</script>" } Output is HTML-escaped or treated as plain text; no script injection src/__tests__/unit/ai/templates.test.ts
template.version.latest Two rows: version 1 (default) and version 2 (default) Resolves version 2, not version 1 src/__tests__/unit/ai/templates.test.ts
template.ab.select Variant A performance=4.2, Variant B performance=4.5, threshold=0.3 Auto-promotes B to default; A marked non-default src/__tests__/unit/ai/templates.test.ts
template.ab.no_promote Variant A performance=4.2, Variant B performance=4.3, threshold=0.3 No promotion; both remain as-is src/__tests__/unit/ai/templates.test.ts

Integration Tests#

Test Setup Action Assertion File
template.db.load Insert practice template with userPromptTemplate: "Write for {{practiceName}}" ai.generateWithTemplate("prac_123", "gbp_post", { practiceName: "Dr. Smith" }) Provider receives prompt "Write for Dr. Smith" src/__tests__/integration/ai/templates.test.ts
template.db.schema Template with outputSchema: { type: "object", required: ["title", "content"] } Generate with jsonMode: true Provider receives schema constraint; output is valid JSON src/__tests__/integration/ai/templates.test.ts
template.db.modelConfig Template with modelConfig: { provider: "openai", model: "gpt-4o", temperature: 0.3 } Generate with this template OpenAI gpt-4o called with temperature: 0.3 src/__tests__/integration/ai/templates.test.ts
template.db.performance Insert A and B variants with scores Run promotion job Winner promoted only if delta ≥ 0.3 src/__tests__/integration/ai/templates.test.ts

Success Criteria (Binary)#

  • Template resolution order is strictly: practice-specific (latest version, isDefault=true) → global default (latest version, isDefault=true) → hardcoded fallback.
  • Variable substitution replaces all {{key}} occurrences with the corresponding value; unmatched keys are left unchanged or raise an error based on config.
  • Template variables are treated as plain text; no injection of HTML, SQL, or script tags into prompts.
  • A/B promotion auto-promotes a variant only when its performanceScore exceeds the control by ≥ 0.3.
  • outputSchema is forwarded to the provider when jsonMode is enabled; invalid JSON output triggers a retry or error.
  • modelConfig from the template is applied to the generation call, overriding global defaults but not client overrides.

Agent Context (Pre-conditions)#

  • Required DB state: PromptTemplate rows with practice-specific, global, A/B variants, and versioned records.
  • Required env vars: DATABASE_URL (test DB).
  • Required external mocks: Provider SDKs to inspect the exact prompt and options sent.

Verification Commands#

# Run template system tests
pnpm test:unit -- src/__tests__/unit/ai/templates.test.ts
pnpm test:integration -- src/__tests__/integration/ai/templates.test.ts

5. Cost Tracking#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
cost.calc.claude_sonnet inputTokens=1000, outputTokens=500, model="claude-sonnet" costUsd = (1000/1e6)*3.00 + (500/1e6)*15.00 = 0.0105 src/__tests__/unit/ai/cost-tracking.test.ts
cost.calc.gpt4o_mini inputTokens=2000, outputTokens=1000, model="gpt-4o-mini" costUsd = (2000/1e6)*0.15 + (1000/1e6)*0.60 = 0.0009 src/__tests__/unit/ai/cost-tracking.test.ts
cost.log.fields Valid generation result logger.info payload contains event, task, model, provider, tokensUsed, inputTokens, outputTokens, costUsd, latencyMs, practiceId, userId, requestId src/__tests__/unit/ai/cost-tracking.test.ts
cost.aggregate.practice 3 generations for practiceId="prac_123" Aggregation query returns total cost, total tokens, count src/__tests__/unit/ai/cost-tracking.test.ts
cost.aggregate.task Generations for task="gbp_post" and task="faq" Grouped by task type with correct subtotals src/__tests__/unit/ai/cost-tracking.test.ts
cost.aggregate.model Generations across Claude and OpenAI models Grouped by provider with correct split src/__tests__/unit/ai/cost-tracking.test.ts
cost.aggregate.daily Generations on 2025-01-15 and 2025-01-16 Grouped by day with correct daily burn rate src/__tests__/unit/ai/cost-tracking.test.ts
cost.alert.daily50 practiceId="prac_123" exceeds $50 in a day Alert emitted: alert: "practice_daily_threshold", threshold: 50 src/__tests__/unit/ai/cost-tracking.test.ts
cost.alert.monthly500 practiceId="prac_123" exceeds $500 in a month Alert emitted: alert: "practice_monthly_threshold", threshold: 500 src/__tests__/unit/ai/cost-tracking.test.ts
cost.alert.system1000 System-wide exceeds $1000 in a day Alert emitted: alert: "system_daily_threshold", threshold: 1000, action="throttle" src/__tests__/unit/ai/cost-tracking.test.ts
cost.alert.no_double Same threshold already alerted today No duplicate alert sent src/__tests__/unit/ai/cost-tracking.test.ts

Integration Tests#

Test Setup Action Assertion File
cost.trpc.response Call api.content.generate Inspect response payload Response contains costUsd, aiTokensUsed, generationTimeMs fields src/__tests__/integration/ai/cost-tracking.test.ts
cost.db.persist Generate content via tRPC Query Content table costUsd, aiTokensUsed, aiProvider, aiModel persisted in DB src/__tests__/integration/ai/cost-tracking.test.ts
cost.alert.webhook Alert threshold crossed Trigger cost alert Webhook/email sent to admin with practice/system context src/__tests__/integration/ai/cost-tracking.test.ts
cost.throttle.action System-wide alert triggered Attempt non-essential generation Non-essential tasks (e.g., social_caption) are throttled; essential tasks (e.g., review_reply) still allowed src/__tests__/integration/ai/cost-tracking.test.ts

Success Criteria (Binary)#

  • Per-request log contains all 11 required fields with correct data types.
  • Cost calculation is exact to 6 decimal places and uses the correct per-model rates.
  • Daily aggregation by practice is accurate and queryable.
  • Monthly aggregation by practice is accurate and queryable.
  • System-wide daily aggregation is accurate and triggers throttling when > $1000.
  • Alerts fire once per threshold per day; no duplicate noise.
  • Throttling blocks non-essential tasks but never blocks essential tasks (review replies, audit reports).
  • Cost metadata is persisted to the Content DB record and returned in the tRPC response.

Agent Context (Pre-conditions)#

  • Required DB state: Content records with costUsd, aiTokensUsed, aiProvider, aiModel, createdAt for aggregation queries.
  • Required env vars: ADMIN_ALERT_WEBHOOK_URL (mocked), DATABASE_URL (test DB).
  • Required external mocks: Alert delivery endpoint (webhook/Resend) mocked.

Verification Commands#

# Run cost tracking tests
pnpm test:unit -- src/__tests__/unit/ai/cost-tracking.test.ts
pnpm test:integration -- src/__tests__/integration/ai/cost-tracking.test.ts

6. Fallback Chain#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
fallback.claude_sonnet Primary: claude-sonnet fails Fallback: gpt-4o src/__tests__/unit/ai/fallback.test.ts
fallback.claude_haiku Primary: claude-haiku fails Fallback: gpt-4o-mini src/__tests__/unit/ai/fallback.test.ts
fallback.gpt4o Primary: gpt-4o fails Fallback: claude-sonnet src/__tests__/unit/ai/fallback.test.ts
fallback.gpt4o_mini Primary: gpt-4o-mini fails Fallback: claude-haiku src/__tests__/unit/ai/fallback.test.ts
fallback.all_fail Primary and fallback both fail Throws FallbackExhaustedError with both error messages src/__tests__/unit/ai/fallback.test.ts
fallback.log Fallback triggered logger.info called with from, to, error fields src/__tests__/unit/ai/fallback.test.ts
fallback.circuit.open 5 failures in 60 seconds Circuit breaker opens; next call throws CircuitOpenError immediately src/__tests__/unit/ai/fallback.test.ts
fallback.circuit.half_open Circuit open, then 1 success Half-open: allows exactly 1 test request src/__tests__/unit/ai/fallback.test.ts
fallback.circuit.closed 3 consecutive successes in half-open Circuit closes; normal flow resumes src/__tests__/unit/ai/fallback.test.ts
fallback.circuit.reset Circuit open, 30 seconds pass Auto-reset to half-open after 30s src/__tests__/unit/ai/fallback.test.ts

Integration Tests#

Test Setup Action Assertion File
fallback.primary_timeout Anthropic SDK times out after 30s ai.generate({ task: "directory_profile_article" }) Falls back to OpenAI gpt-4o; result is valid content src/__tests__/integration/ai/fallback.test.ts
fallback.primary_500 Anthropic SDK returns HTTP 500 ai.generate({ task: "gbp_post" }) Falls back to OpenAI gpt-4o-mini; result is valid content src/__tests__/integration/ai/fallback.test.ts
fallback.primary_rate_limit Anthropic SDK returns HTTP 429 ai.generate({ task: "schema_markup" }) Falls back to OpenAI gpt-4o; result is valid JSON-LD src/__tests__/integration/ai/fallback.test.ts
fallback.secondary_fail Both Anthropic and OpenAI fail ai.generate({ task: "faq" }) Throws FallbackExhaustedError; logs incident; alert sent to admin src/__tests__/integration/ai/fallback.test.ts
fallback.circuit_integration Rapid-fire 5 failing requests 6th request 6th request immediately rejected with CircuitOpenError (no provider call) src/__tests__/integration/ai/fallback.test.ts
fallback.circuit_recovery Circuit open, wait 30s, then 1 success, then 2 more 4th request after 3 successes 4th request succeeds normally; circuit is CLOSED src/__tests__/integration/ai/fallback.test.ts

Success Criteria (Binary)#

  • Every primary model has exactly one documented fallback model; fallback rules are bi-directional and exhaustive.
  • Primary failure triggers fallback within < 100ms of error detection (no retry loops on primary).
  • Fallback success returns a valid GenerateResult with model and provider reflecting the fallback, not the primary.
  • Both primary and fallback failure throws FallbackExhaustedError with both errors, logs an incident, and notifies admin.
  • Circuit breaker opens after 5 failures in 60 seconds; blocks all requests for 30 seconds.
  • Circuit breaker transitions to half-open after 30 seconds or 1 success; allows 1 test request.
  • Circuit breaker closes after 3 consecutive successes in half-open state.
  • Open circuit rejects requests immediately without calling any provider (saves cost and latency).

Agent Context (Pre-conditions)#

  • Required DB state: Minimal PromptTemplate rows so generation can proceed.
  • Required env vars: ANTHROPIC_API_KEY, OPENAI_API_KEY (both mocked).
  • Required external mocks: Anthropic and OpenAI generateText mocked with configurable failure modes (timeout, 500, 429, success).

Verification Commands#

# Run fallback chain tests
pnpm test:unit -- src/__tests__/unit/ai/fallback.test.ts
pnpm test:integration -- src/__tests__/integration/ai/fallback.test.ts

7. Provider Integrations#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
provider.anthropic.create apiKey provided createAnthropic returns valid client src/__tests__/unit/ai/providers.test.ts
provider.anthropic.generate model="claude-sonnet-4-20250514", prompt="Hello" generateText returns { text, usage: { promptTokens, completionTokens } } src/__tests__/unit/ai/providers.test.ts
provider.anthropic.system system: "You are a doctor" generateText receives system parameter src/__tests__/unit/ai/providers.test.ts
provider.anthropic.maxTokens maxTokens: 2048 generateText receives maxTokens: 2048 src/__tests__/unit/ai/providers.test.ts
provider.anthropic.temperature temperature: 0.3 generateText receives temperature: 0.3 src/__tests__/unit/ai/providers.test.ts
provider.openai.create apiKey provided createOpenAI returns valid client src/__tests__/unit/ai/providers.test.ts
provider.openai.generate model="gpt-4o", prompt="Hello" generateText returns { text, usage: { promptTokens, completionTokens } } src/__tests__/unit/ai/providers.test.ts
provider.openai.jsonMode jsonMode: true generateText receives response_format: { type: "json_object" } src/__tests__/unit/ai/providers.test.ts
provider.openai.system system: "You are a dentist" generateText receives system parameter src/__tests__/unit/ai/providers.test.ts
provider.unsupported provider: "gemini" Throws ProviderError: Unsupported provider src/__tests__/unit/ai/providers.test.ts

Integration Tests#

Test Setup Action Assertion File
provider.anthropic.mock Mock Anthropic generateText with msw generateWithClaude("claude-sonnet-4-20250514", options) Result matches mock response shape; cost and latency calculated src/__tests__/integration/ai/providers.test.ts
provider.openai.mock Mock OpenAI generateText with msw generateWithOpenAI("gpt-4o", options) Result matches mock response shape; cost and latency calculated src/__tests__/integration/ai/providers.test.ts
provider.anthropic.timeout Mock Anthropic delay > 30s generateWithClaude(...) with timeout config Throws ProviderError: Request timeout or falls back src/__tests__/integration/ai/providers.test.ts
provider.openai.rate_limit Mock OpenAI returns 429 with retry-after generateWithOpenAI(...) Waits for retry-after header or falls back immediately src/__tests__/integration/ai/providers.test.ts
provider.anthropic.malformed Mock Anthropic returns 200 with missing usage generateWithClaude(...) Throws ProviderError: Invalid response shape or estimates tokens src/__tests__/integration/ai/providers.test.ts
provider.openai.malformed Mock OpenAI returns 200 with missing usage generateWithOpenAI(...) Throws ProviderError: Invalid response shape or estimates tokens src/__tests__/integration/ai/providers.test.ts
provider.streaming.anthropic Mock streamText with chunked chunks streamText({ model: anthropic("claude-haiku"), ... }) Returns readable stream; chunks contain text deltas src/__tests__/integration/ai/providers.test.ts
provider.streaming.openai Mock streamText with chunked chunks streamText({ model: openai("gpt-4o-mini"), ... }) Returns readable stream; chunks contain text deltas src/__tests__/integration/ai/providers.test.ts

Mock Responses#

Anthropic Success Mock

{
  "id": "msg_01XgYxVjCJ8Kz3vRq9LmNpQ2",
  "type": "message",
  "role": "assistant",
  "content": [
    { "type": "text", "text": "Discover the latest in dental implant technology at Dr. Smith Dental Clinic. Schedule your consultation today!" }
  ],
  "model": "claude-sonnet-4-20250514",
  "usage": {
    "input_tokens": 120,
    "output_tokens": 45
  }
}

Anthropic Error Mock (500)

{
  "type": "error",
  "error": {
    "type": "api_error",
    "message": "Internal server error"
  }
}

Anthropic Error Mock (429)

{
  "type": "error",
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limit exceeded"
  }
}

OpenAI Success Mock

{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "model": "gpt-4o",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Best dentist in Kochi | Dr. Smith Dental Clinic offers top-rated teeth whitening, root canal, and braces treatments."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 85,
    "completion_tokens": 32,
    "total_tokens": 117
  }
}

OpenAI Error Mock (500)

{
  "error": {
    "message": "The server had an error while processing your request.",
    "type": "server_error",
    "param": null,
    "code": null
  }
}

OpenAI Error Mock (429)

{
  "error": {
    "message": "Rate limit reached",
    "type": "requests",
    "param": null,
    "code": "rate_limit_exceeded"
  }
}

Success Criteria (Binary)#

  • Anthropic provider initializes with createAnthropic and calls generateText with all options passed through.
  • OpenAI provider initializes with createOpenAI and calls generateText with response_format when jsonMode is true.
  • Both providers return GenerateResult with text, model, provider, tokensUsed, costUsd, and latencyMs.
  • Token usage is extracted from usage.promptTokens + usage.completionTokens (Anthropic) or usage.totalTokens (OpenAI).
  • Unsupported providers throw ProviderError immediately; no silent fallback to a default provider.
  • Malformed API responses (missing usage, missing text) throw ProviderError with a clear message.
  • Streaming mode returns a valid data stream with text chunks for both providers.
  • Mock responses for success, 500, and 429 cases are documented and used in contract tests.

Agent Context (Pre-conditions)#

  • Required DB state: None (provider-level tests).
  • Required env vars: ANTHROPIC_API_KEY (mocked), OPENAI_API_KEY (mocked).
  • Required external mocks: msw handlers for https://api.anthropic.com/v1/messages and https://api.openai.com/v1/chat/completions returning the mock responses above.

Verification Commands#

# Run provider integration tests
pnpm test:unit -- src/__tests__/unit/ai/providers.test.ts
pnpm test:integration -- src/__tests__/integration/ai/providers.test.ts
pnpm test:contract -- src/__tests__/contract/ai/providers.test.ts

8. Content Types#

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
content.gbp.length Prompt: "Dr. Smith Dental Clinic, Kochi, teeth whitening" Generated text length between 150 and 300 characters src/__tests__/unit/ai/content-types.test.ts
content.gbp.cta Prompt for GBP post Text contains a call-to-action ("Schedule", "Call", "Visit", "Book") src/__tests__/unit/ai/content-types.test.ts
content.landing.length Prompt: "best dentist in Kochi, 800 words" Generated text length between 500 and 1000 words src/__tests__/unit/ai/content-types.test.ts
content.landing.keyword_density Prompt with target keyword Keyword density < 2% in output text src/__tests__/unit/ai/content-types.test.ts
content.review.empathy Negative review input Reply contains apologetic or empathetic language src/__tests__/unit/ai/content-types.test.ts
content.review.no_phi Review with patient name "John Doe" Reply does not contain "John Doe" or any patient name src/__tests__/unit/ai/content-types.test.ts
content.citation.unique Same inputs, 2 calls Two outputs are not identical (uniqueness) src/__tests__/unit/ai/content-types.test.ts
content.citation.length Prompt: "Dr. Smith, Kochi, dental services, Google" Output length between 100 and 200 words src/__tests__/unit/ai/content-types.test.ts
content.social.platform platform: "instagram" Output respects Instagram character limit and hashtag rules src/__tests__/unit/ai/content-types.test.ts
content.schema.valid_json Prompt: "DentalClinic, Kochi, teeth whitening" Output is valid JSON-LD string; parses with JSON.parse src/__tests__/unit/ai/content-types.test.ts
content.schema.schema_org Valid JSON-LD output Contains @context: "https://schema.org" and @type src/__tests__/unit/ai/content-types.test.ts
content.faq.count Prompt: "dental clinic, common questions" Output contains 5–10 Q&A pairs src/__tests__/unit/ai/content-types.test.ts
content.faq.accuracy FAQ about root canals Does not contain medically incorrect claims (e.g., "root canals are 100% painless") src/__tests__/unit/ai/content-types.test.ts

Integration Tests#

Test Setup Action Assertion File
content.gbp.medical_filter Mock response containing "100% cure guaranteed" ai.generate({ task: "gbp_post" }) Medical safety filter rejects content; triggers regeneration or returns error src/__tests__/integration/ai/content-types.test.ts
content.landing.originality Mock response with plagiarized text ai.generate({ task: "directory_profile_article" }) Plagiarism/similarity check flags content; triggers regeneration or error src/__tests__/integration/ai/content-types.test.ts
content.review.hipaa Mock reply containing patient name ai.generate({ task: "review_reply" }) HIPAA filter strips patient name; sanitized reply returned src/__tests__/integration/ai/content-types.test.ts
content.schema.google_validate Generated JSON-LD Validate against Google Rich Results Test schema No critical errors; MedicalBusiness or LocalBusiness type present src/__tests__/integration/ai/content-types.test.ts
content.faq.disclaimer FAQ with medical advice Generated output Contains disclaimer if needed (e.g., "Consult your dentist for personalized advice") src/__tests__/integration/ai/content-types.test.ts
content.citation.duplicate Same practice, same directory, 2nd generation ai.generate({ task: "citation_description" }) Second output is different from first (no duplicate descriptions) src/__tests__/integration/ai/content-types.test.ts
content.social.hashtag platform: "twitter" Generated caption Respects 280-character limit; hashtag count ≤ 3 for Twitter src/__tests__/integration/ai/content-types.test.ts

Compliance & Medical Safety Tests#

Test Trigger Check Failure Action File
compliance.no_guarantees Any AI content generation Regex scan for banned phrases: 100% cure, guaranteed results, permanent fix, no side effects Reject + regenerate; log compliance event src/__tests__/compliance/ai-safety.test.ts
compliance.no_drug_claims Any AI content generation Entity recognition for drug names + claim verbs Flag for human review; block auto-publish src/__tests__/compliance/ai-safety.test.ts
compliance.hipaa_safe review_reply generation No patient names, phone numbers, appointment dates, or medical record numbers in output Strip + regenerate; alert if 2nd attempt also fails src/__tests__/compliance/ai-safety.test.ts
compliance.medical_disclaimer faq or gbp_post with medical advice Output contains disclaimer or is tagged needs_disclaimer Add disclaimer before publish; do not publish without it src/__tests__/compliance/ai-safety.test.ts
compliance.schema_valid schema_markup generation JSON-LD parses and contains required Schema.org properties Fallback to minimal valid schema; alert admin src/__tests__/compliance/ai-safety.test.ts
compliance.consent_log Any AI-generated content approved for publish DB record in ConsentLog with action: "ai_content_approved", ip, timestamp, userId Block progression if log missing src/__tests__/compliance/ai-safety.test.ts

Success Criteria (Binary)#

  • GBP post output is 150–300 characters and contains a call-to-action.
  • Directory profile article is 500–1000 words with keyword density < 2%.
  • Review replies are empathetic for negative reviews and never contain PHI (patient names, contact info, dates).
  • Citation descriptions are 100–200 words and unique per directory (no duplicates for the same practice).
  • Social captions respect platform-specific character limits and hashtag rules.
  • Schema markup is valid JSON-LD with @context: "https://schema.org" and a valid @type.
  • FAQ outputs contain 5–10 Q&A pairs with accurate medical information.
  • Medical safety filter rejects content with banned phrases (100% cure, guaranteed results, etc.) and triggers regeneration.
  • Drug claim detection flags content with drug names + unverified claims for human review.
  • HIPAA filter strips PHI from review replies; if stripping fails twice, the content is rejected.
  • Medical advice content either includes a disclaimer or is tagged needs_disclaimer before publish.
  • AI content approval triggers a ConsentLog record with IP, timestamp, and user ID.

Agent Context (Pre-conditions)#

  • Required DB state: PromptTemplate rows for each content type; ConsentLog table empty before approval tests.
  • Required env vars: ANTHROPIC_API_KEY, OPENAI_API_KEY (mocked); DATABASE_URL (test DB).
  • Required external mocks: Provider SDKs returning content that passes and fails compliance checks.
  • Required fixtures: Pre-defined mock responses that contain banned phrases and PHI for negative tests.

Verification Commands#

# Run content type tests
pnpm test:unit -- src/__tests__/unit/ai/content-types.test.ts
pnpm test:integration -- src/__tests__/integration/ai/content-types.test.ts
pnpm test:compliance -- src/__tests__/compliance/ai-safety.test.ts

9. Verification Commands#

Full AI Services Test Suite#

# ───────────────────────────────────────────────
# 1. Unit Tests (fast, isolated)
# ───────────────────────────────────────────────
pnpm test:unit -- src/__tests__/unit/ai/

# Individual unit test files
pnpm test:unit -- src/__tests__/unit/ai/router.test.ts
pnpm test:unit -- src/__tests__/unit/ai/model-config.test.ts
pnpm test:unit -- src/__tests__/unit/ai/task-mapping.test.ts
pnpm test:unit -- src/__tests__/unit/ai/templates.test.ts
pnpm test:unit -- src/__tests__/unit/ai/cost-tracking.test.ts
pnpm test:unit -- src/__tests__/unit/ai/fallback.test.ts
pnpm test:unit -- src/__tests__/unit/ai/providers.test.ts
pnpm test:unit -- src/__tests__/unit/ai/content-types.test.ts

# ───────────────────────────────────────────────
# 2. Integration Tests (DB + mocked providers)
# ───────────────────────────────────────────────
pnpm test:integration -- src/__tests__/integration/ai/

# Individual integration test files
pnpm test:integration -- src/__tests__/integration/ai/router.test.ts
pnpm test:integration -- src/__tests__/integration/ai/model-config.test.ts
pnpm test:integration -- src/__tests__/integration/ai/task-mapping.test.ts
pnpm test:integration -- src/__tests__/integration/ai/templates.test.ts
pnpm test:integration -- src/__tests__/integration/ai/cost-tracking.test.ts
pnpm test:integration -- src/__tests__/integration/ai/fallback.test.ts
pnpm test:integration -- src/__tests__/integration/ai/providers.test.ts
pnpm test:integration -- src/__tests__/integration/ai/content-types.test.ts

# ───────────────────────────────────────────────
# 3. Contract Tests (provider API shapes)
# ───────────────────────────────────────────────
pnpm test:contract -- src/__tests__/contract/ai/providers.test.ts

# ───────────────────────────────────────────────
# 4. Compliance Tests (medical safety)
# ───────────────────────────────────────────────
pnpm test:compliance -- src/__tests__/compliance/ai-safety.test.ts

# ───────────────────────────────────────────────
# 5. Full AI Services Suite (all categories)
# ───────────────────────────────────────────────
pnpm test -- src/__tests__/unit/ai/ src/__tests__/integration/ai/ src/__tests__/compliance/ai-safety.test.ts

Coverage Target#

# Run with coverage report
pnpm test:unit --coverage -- src/__tests__/unit/ai/
Metric Target Fail If Below
Statements ≥ 90% 85%
Branches ≥ 85% 80%
Functions ≥ 90% 85%
Lines ≥ 90% 85%

Pre-commit Gate#

# All AI tests must pass before any commit touching src/server/services/ai/
pnpm test:unit -- src/__tests__/unit/ai/ && pnpm test:integration -- src/__tests__/integration/ai/

End of AI Services Test Specification — RankFlow AI v1.0.0