Browse documentation

Test Specs

RankFlow AI — Backend API Test Specification

// src/tests/factories/user.ts

docs/test-specs/TEST-backend-api.md
On this page

Version: 1.0.0
Date: 2026-06-13
Scope: All 14 tRPC routers (auth, practice, location, gbp, social, content, citation, site, report, lead, billing, skill, admin)
Format: Follows docs/test-plan.md Section 3 — Success Criteria Template


1. Test Data Factories#

1.1 Core Factories#

// src/__tests__/factories/user.ts
import { faker } from "@faker-js/faker";

export const createMockUser = (overrides?: Partial<User>) => ({
  id: `usr_${faker.string.nanoid(8)}`,
  email: faker.internet.email(),
  name: faker.person.fullName(),
  image: faker.image.avatar(),
  role: "USER",
  emailVerified: new Date(),
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

export const createMockSession = (overrides?: Partial<Session>) => ({
  id: `sess_${faker.string.nanoid(8)}`,
  token: faker.string.uuid(),
  userId: `usr_${faker.string.nanoid(8)}`,
  expiresAt: new Date(Date.now() + 86400000),
  ipAddress: faker.internet.ip(),
  userAgent: faker.internet.userAgent(),
  ...overrides,
});
// src/__tests__/factories/practice.ts
export const createMockPractice = (overrides?: Partial<Practice>) => ({
  id: `prac_${faker.string.nanoid(8)}`,
  name: faker.company.name(),
  type: faker.helpers.arrayElement([
    "CLINIC", "HOSPITAL", "DIAGNOSTIC_CENTER", "DENTAL_CLINIC",
    "PHYSIOTHERAPY", "AYURVEDIC_CENTER", "HOMEOPATHY_CLINIC",
    "CA", "LAWYER", "WEDDING_PHOTOGRAPHER",
  ]),
  slug: faker.internet.domainWord(),
  logoUrl: faker.image.url(),
  primaryColor: "#3B82F6",
  settings: {},
  status: "ACTIVE",
  tier: "STANDARD",
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

export const createMockPracticeMember = (overrides?: Partial<PracticeMember>) => ({
  id: `pm_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  userId: `usr_${faker.string.nanoid(8)}`,
  role: faker.helpers.arrayElement(["OWNER", "ADMIN", "EDITOR", "VIEWER"]),
  joinedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/location.ts
export const createMockLocation = (overrides?: Partial<Location>) => ({
  id: `loc_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  name: faker.location.streetAddress(),
  businessName: faker.company.name(),
  address: faker.location.streetAddress(),
  city: faker.location.city(),
  state: faker.location.state(),
  postalCode: faker.location.zipCode(),
  country: "IN",
  phone: faker.string.numeric(10),
  phoneSecondary: faker.string.numeric(10),
  email: faker.internet.email(),
  website: faker.internet.url(),
  category: "Medical Clinic",
  services: ["General Checkup", "Vaccination"],
  businessHours: { monday: "9:00-18:00" },
  targetKeywords: ["doctor near me", "best clinic"],
  serviceAreas: [faker.location.city()],
  languages: ["English", "Hindi"],
  latitude: faker.location.latitude(),
  longitude: faker.location.longitude(),
  isPrimary: false,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/content.ts
export const createMockContentPiece = (overrides?: Partial<ContentPiece>) => ({
  id: `cnt_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  locationId: `loc_${faker.string.nanoid(8)}`,
  taskType: "GBP_POST",
  title: faker.lorem.sentence(),
  content: faker.lorem.paragraphs(3),
  seoTitle: faker.lorem.sentence(5),
  seoDescription: faker.lorem.sentence(10),
  focusKeywords: ["keyword1", "keyword2"],
  status: faker.helpers.arrayElement(["DRAFT", "PENDING", "APPROVED", "REJECTED", "PUBLISHED"]),
  model: "claude-sonnet",
  costUsd: 0.002,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/gbp.ts
export const createMockGbpPost = (overrides?: Partial<GbpPost>) => ({
  id: `gbp_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  locationId: `loc_${faker.string.nanoid(8)}`,
  content: faker.lorem.paragraph(),
  mediaUrls: [faker.image.url()],
  ctaType: "BOOK",
  topicType: "STANDARD",
  status: "PUBLISHED",
  gbpPostId: faker.string.uuid(),
  scheduledFor: null,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

export const createMockGbpAccount = (overrides?: Partial<GbpAccount>) => ({
  id: `gba_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  googleAccountId: faker.string.uuid(),
  email: faker.internet.email(),
  accessToken: faker.string.alphanumeric(100),
  refreshToken: faker.string.alphanumeric(100),
  expiresAt: new Date(Date.now() + 3600000),
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/social.ts
export const createMockSocialAccount = (overrides?: Partial<SocialAccount>) => ({
  id: `soc_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  platform: faker.helpers.arrayElement(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
  accountName: faker.internet.userName(),
  accountId: faker.string.uuid(),
  accessToken: faker.string.alphanumeric(100),
  refreshToken: faker.string.alphanumeric(100),
  expiresAt: new Date(Date.now() + 3600000),
  isActive: true,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

export const createMockSocialPost = (overrides?: Partial<SocialPost>) => ({
  id: `sp_${faker.string.nanoid(8)}`,
  socialAccountId: `soc_${faker.string.nanoid(8)}`,
  content: faker.lorem.paragraph(),
  mediaUrls: [faker.image.url()],
  mediaType: "IMAGE",
  hashtags: ["#health", "#wellness"],
  linkUrl: faker.internet.url(),
  status: "SCHEDULED",
  scheduledFor: new Date(Date.now() + 3600000),
  publishedAt: null,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/citation.ts
export const createMockCitation = (overrides?: Partial<Citation>) => ({
  id: `cit_${faker.string.nanoid(8)}`,
  locationId: `loc_${faker.string.nanoid(8)}`,
  directoryName: faker.helpers.arrayElement(["justdial", "practo", "lybrate", "google"]),
  url: faker.internet.url(),
  status: faker.helpers.arrayElement(["PENDING", "SUBMITTED", "VERIFIED", "ERROR"]),
  napMatch: null,
  lastVerifiedAt: null,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

export const createMockCitationDirectory = (overrides?: Partial<CitationDirectory>) => ({
  id: `cd_${faker.string.nanoid(8)}`,
  name: faker.helpers.arrayElement(["justdial", "practo", "lybrate", "google", "facebook", "yellowpages"]),
  category: "medical",
  domainAuthority: faker.number.int({ min: 20, max: 90 }),
  isFree: faker.datatype.boolean(),
  requiresPhoneVerification: faker.datatype.boolean(),
  createdAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/lead.ts
export const createMockLead = (overrides?: Partial<Lead>) => ({
  id: `ld_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  locationId: `loc_${faker.string.nanoid(8)}`,
  name: faker.person.fullName(),
  email: faker.internet.email(),
  phone: faker.string.numeric(10),
  source: faker.helpers.arrayElement(["GBP", "WEBSITE", "SOCIAL", "REFERRAL"]),
  status: faker.helpers.arrayElement(["NEW", "CONTACTED", "CONVERTED", "LOST"]),
  message: faker.lorem.sentence(),
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/billing.ts
export const createMockInvoice = (overrides?: Partial<Invoice>) => ({
  id: `inv_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  amount: faker.number.int({ min: 1000, max: 50000 }),
  currency: "INR",
  status: faker.helpers.arrayElement(["PENDING", "PAID", "OVERDUE", "REFUNDED"]),
  stripeInvoiceId: faker.string.uuid(),
  periodStart: new Date(),
  periodEnd: new Date(Date.now() + 2592000000),
  paidAt: null,
  createdAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/report.ts
export const createMockReport = (overrides?: Partial<Report>) => ({
  id: `rep_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  type: "MONTHLY",
  periodStart: new Date(Date.now() - 2592000000),
  periodEnd: new Date(),
  status: faker.helpers.arrayElement(["PENDING", "GENERATING", "READY", "FAILED"]),
  pdfUrl: null,
  data: {},
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});
// src/__tests__/factories/admin.ts
export const createMockJob = (overrides?: Partial<Job>) => ({
  id: `job_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  type: faker.helpers.arrayElement(["CONTENT_GENERATE", "CITATION_SUBMIT", "REPORT_GENERATE", "SITE_PUBLISH"]),
  status: faker.helpers.arrayElement(["PENDING", "RUNNING", "COMPLETED", "FAILED", "RETRYING"]),
  payload: {},
  result: null,
  error: null,
  attempts: 0,
  maxAttempts: 3,
  scheduledFor: new Date(),
  startedAt: null,
  completedAt: null,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

export const createMockPromptTemplate = (overrides?: Partial<PromptTemplate>) => ({
  id: `pt_${faker.string.nanoid(8)}`,
  taskType: "GBP_POST",
  systemPrompt: faker.lorem.paragraphs(2),
  userPromptTemplate: faker.lorem.paragraphs(3),
  modelConfig: {
    provider: "anthropic",
    model: "claude-sonnet-4",
    temperature: 0.7,
    maxTokens: 2000,
  },
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

2. Auth Router#

Spec Reference: docs/specs/backend-api.md Section 2
File: src/server/api/routers/auth.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
updateProfileSchema.valid { name: "John Doe", image: "https://example.com/avatar.png" } Passes validation src/__tests__/unit/schemas/auth.test.ts
updateProfileSchema.nameTooLong { name: "a".repeat(101) } Zod error: max 100 chars src/__tests__/unit/schemas/auth.test.ts
updateProfileSchema.invalidUrl { image: "not-a-url" } Zod error: invalid URL src/__tests__/unit/schemas/auth.test.ts
changePasswordSchema.valid { currentPassword: "oldpass123", newPassword: "newpass123" } Passes validation src/__tests__/unit/schemas/auth.test.ts
changePasswordSchema.shortPassword { newPassword: "short" } Zod error: min 8 chars src/__tests__/unit/schemas/auth.test.ts
changePasswordSchema.longPassword { newPassword: "a".repeat(101) } Zod error: max 100 chars src/__tests__/unit/schemas/auth.test.ts

Integration Tests#

Test Setup Action Assertion File
auth.me.authenticated Mock session with user Call auth.me Returns user object with correct fields src/__tests__/integration/routers/auth.test.ts
auth.me.unauthenticated No session Call auth.me Throws UNAUTHORIZED (401) src/__tests__/integration/routers/auth.test.ts
auth.updateProfile.success Mock user in DB Call auth.updateProfile with valid name DB updated, returns updated user src/__tests__/integration/routers/auth.test.ts
auth.changePassword.success Mock user with password hash Call with valid current + new password Password hash updated, returns { success: true } src/__tests__/integration/routers/auth.test.ts
auth.changePassword.wrongCurrent Mock user with password hash Call with wrong current password Throws BAD_REQUEST (400) src/__tests__/integration/routers/auth.test.ts
auth.listSessions Mock 3 sessions for user Call auth.listSessions Returns array of 3 sessions src/__tests__/integration/routers/auth.test.ts
auth.revokeSession Mock session token Call auth.revokeSession with token Session deleted from DB, returns { success: true } src/__tests__/integration/routers/auth.test.ts
auth.revokeSession.ownSession Attempt to revoke own active session Call with own token Throws FORBIDDEN (403) or prevents deletion src/__tests__/integration/routers/auth.test.ts

E2E Tests#

Flow Steps Expected End State File
auth.profileUpdateFlow 1. Login 2. Update profile 3. Refresh page Profile displays new name e2e/auth/profile.spec.ts

Success Criteria (Binary)#

  • auth.me returns full user object for authenticated requests
  • auth.me returns 401 for unauthenticated requests
  • auth.updateProfile validates name length (1-100 chars) and image URL format
  • auth.updateProfile persists changes to database
  • auth.changePassword requires current password match
  • auth.changePassword enforces new password length (8-100 chars)
  • auth.changePassword hashes new password before storage
  • auth.listSessions returns only sessions belonging to current user
  • auth.revokeSession deletes target session from database
  • auth.revokeSession prevents revoking the currently active session

Agent Context (Pre-conditions)#

  • Required DB state: User table with at least 1 user, Session table with 3+ sessions for that user
  • Required env vars: BETTER_AUTH_SECRET configured
  • Required external mocks: None (pure internal auth)

Verification Commands#

# Run unit tests for auth schemas
pnpm test:unit -- src/__tests__/unit/schemas/auth.test.ts

# Run integration tests for auth router
pnpm test:integration -- src/__tests__/integration/routers/auth.test.ts

# Run auth E2E flow
pnpm test:e2e -- e2e/auth/profile.spec.ts

3. Practice Router#

Spec Reference: docs/specs/backend-api.md Section 3
File: src/server/api/routers/practice.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
createPracticeSchema.valid { name: "Smile Dental", type: "DENTAL_CLINIC" } Passes validation src/__tests__/unit/schemas/practice.test.ts
createPracticeSchema.nameTooShort { name: "A", type: "CLINIC" } Zod error: min 2 chars src/__tests__/unit/schemas/practice.test.ts
createPracticeSchema.invalidType { name: "Test", type: "INVALID" } Zod error: invalid enum value src/__tests__/unit/schemas/practice.test.ts
createPracticeSchema.invalidSlug { name: "Test", slug: "invalid_slug!" } Zod error: regex mismatch src/__tests__/unit/schemas/practice.test.ts
updatePracticeSchema.validColor { id: "prac_123", primaryColor: "#3B82F6" } Passes validation src/__tests__/unit/schemas/practice.test.ts
updatePracticeSchema.invalidColor { id: "prac_123", primaryColor: "blue" } Zod error: invalid hex format src/__tests__/unit/schemas/practice.test.ts
updatePracticeSchema.invalidUrl { id: "prac_123", logoUrl: "not-url" } Zod error: invalid URL src/__tests__/unit/schemas/practice.test.ts

Integration Tests#

Test Setup Action Assertion File
practice.create.success Authenticated user, no practice Call practice.create with valid input Practice created, user becomes OWNER src/__tests__/integration/routers/practice.test.ts
practice.create.duplicateSlug Existing practice with slug "test" Call with same slug Throws CONFLICT (409) src/__tests__/integration/routers/practice.test.ts
practice.get.success Practice + member relation Call practice.get with valid ID Returns practice with correct ID src/__tests__/integration/routers/practice.test.ts
practice.get.notMember Practice exists, user not member Call practice.get Throws FORBIDDEN (403) src/__tests__/integration/routers/practice.test.ts
practice.update.success Practice exists, user is OWNER Call practice.update with name change DB updated, returns updated practice src/__tests__/integration/routers/practice.test.ts
practice.delete.success Practice exists, user is OWNER Call practice.delete Practice soft-deleted or removed src/__tests__/integration/routers/practice.test.ts
practice.delete.notOwner Practice exists, user is EDITOR Call practice.delete Throws FORBIDDEN (403) src/__tests__/integration/routers/practice.test.ts
practice.list User member of 2 practices Call practice.list Returns array of 2 practices src/__tests__/integration/routers/practice.test.ts
practice.inviteMember Practice exists, user is OWNER Call practice.inviteMember with email Invite record created, returns inviteUrl src/__tests__/integration/routers/practice.test.ts
practice.inviteMember.editorAttempt Practice exists, user is EDITOR Call practice.inviteMember Throws FORBIDDEN (403) src/__tests__/integration/routers/practice.test.ts
practice.removeMember Practice with 2 members, OWNER caller Call practice.removeMember with member userId Member removed from practice src/__tests__/integration/routers/practice.test.ts
practice.updateMemberRole Practice with EDITOR member, OWNER caller Call with role change to ADMIN Role updated, returns updated member src/__tests__/integration/routers/practice.test.ts

E2E Tests#

Flow Steps Expected End State File
practice.onboardingFlow 1. Sign up 2. Create practice 3. Verify dashboard Practice visible in sidebar e2e/practice/onboarding.spec.ts

Success Criteria (Binary)#

  • practice.create enforces name length (2-100 chars) and valid type enum
  • practice.create auto-generates slug if not provided
  • practice.create validates slug format ^[a-z0-9-]+$
  • practice.create returns CONFLICT on duplicate slug
  • practice.create sets creator as OWNER in PracticeMember
  • practice.get returns 404 for non-existent practice ID
  • practice.get returns 403 for non-member users
  • practice.update validates hex color format ^#[0-9A-Fa-f]{6}$
  • practice.update validates logoUrl as URL
  • practice.delete requires OWNER role
  • practice.delete cascades or handles related locations/content
  • practice.list returns only practices where user is a member
  • practice.inviteMember generates valid invite URL with token
  • practice.inviteMember sends email via Resend mock
  • practice.removeMember prevents removing the OWNER
  • practice.updateMemberRole accepts only valid roles (OWNER, ADMIN, EDITOR, VIEWER)
  • practice.updateMemberRole prevents changing OWNER role

Agent Context (Pre-conditions)#

  • Required DB state: User table with 2+ users, Practice table with 1+ practice, PracticeMember table with role mappings
  • Required env vars: RESEND_API_KEY (for invite email mock)
  • Required external mocks: Resend API mock (200 OK + messageId)

Verification Commands#

# Run unit tests for practice schemas
pnpm test:unit -- src/__tests__/unit/schemas/practice.test.ts

# Run integration tests for practice router
pnpm test:integration -- src/__tests__/integration/routers/practice.test.ts

# Run practice onboarding E2E
pnpm test:e2e -- e2e/practice/onboarding.spec.ts

4. Location Router#

Spec Reference: docs/specs/backend-api.md Section 4
File: src/server/api/routers/location.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
createLocationSchema.valid Full valid location input Passes validation src/__tests__/unit/schemas/location.test.ts
createLocationSchema.shortAddress { address: "123" } Zod error: min 5 chars src/__tests__/unit/schemas/location.test.ts
createLocationSchema.invalidEmail { email: "not-email" } Zod error: invalid email src/__tests__/unit/schemas/location.test.ts
createLocationSchema.invalidUrl { website: "not-url" } Zod error: invalid URL src/__tests__/unit/schemas/location.test.ts
createLocationSchema.phoneTooShort { phone: "12345" } Zod error: min 10 chars src/__tests__/unit/schemas/location.test.ts
createLocationSchema.defaultCountry Input without country Country defaults to "IN" src/__tests__/unit/schemas/location.test.ts
createLocationSchema.defaultLanguages Input without languages Languages defaults to ["English", "Hindi"] src/__tests__/unit/schemas/location.test.ts

Integration Tests#

Test Setup Action Assertion File
location.create.success Practice member context Call location.create with valid input Location created with practiceId set src/__tests__/integration/routers/location.test.ts
location.create.invalidPractice User not in practice Call location.create Throws FORBIDDEN (403) or BAD_REQUEST src/__tests__/integration/routers/location.test.ts
location.get.success Location exists in practice Call location.get with valid ID Returns location with all fields src/__tests__/integration/routers/location.test.ts
location.get.wrongPractice Location belongs to other practice Call location.get Throws FORBIDDEN (403) src/__tests__/integration/routers/location.test.ts
location.update.success Location exists Call location.update with phone change DB updated, returns updated location src/__tests__/integration/routers/location.test.ts
location.delete.success Location exists Call location.delete Location removed from DB src/__tests__/integration/routers/location.test.ts
location.list 3 locations in practice Call location.list Returns array of 3 locations src/__tests__/integration/routers/location.test.ts
location.setPrimary 2 locations, none primary Call location.setPrimary on loc2 loc2.isPrimary = true, loc1.isPrimary = false src/__tests__/integration/routers/location.test.ts
location.setPrimary.unsetPrevious loc1 is primary, set loc2 Call location.setPrimary on loc2 Previous primary unset, new primary set src/__tests__/integration/routers/location.test.ts

E2E Tests#

Flow Steps Expected End State File
location.crudFlow 1. Create location 2. Update phone 3. Set primary 4. Delete Location list reflects changes e2e/location/crud.spec.ts

Success Criteria (Binary)#

  • location.create validates address minimum 5 characters
  • location.create validates phone as 10-15 characters
  • location.create validates email format when provided
  • location.create validates directory profile URL format when provided
  • location.create defaults country to "IN"
  • location.create defaults languages to ["English", "Hindi"]
  • location.create defaults services to []
  • location.create associates location with caller's practice context
  • location.get returns 404 for non-existent location ID
  • location.get returns 403 for location outside caller's practice
  • location.update allows partial updates (all fields optional)
  • location.delete removes location from database
  • location.list returns only locations in caller's practice
  • location.setPrimary sets isPrimary = true on target location
  • location.setPrimary unsets isPrimary on all other locations in same practice
  • location.setPrimary returns updated location with isPrimary = true

Agent Context (Pre-conditions)#

  • Required DB state: Practice with 1+ member, Location table with 2+ locations for that practice
  • Required env vars: None
  • Required external mocks: None

Verification Commands#

# Run unit tests for location schemas
pnpm test:unit -- src/__tests__/unit/schemas/location.test.ts

# Run integration tests for location router
pnpm test:integration -- src/__tests__/integration/routers/location.test.ts

# Run location CRUD E2E
pnpm test:e2e -- e2e/location/crud.spec.ts

5. GBP Router#

Spec Reference: docs/specs/backend-api.md Section 5
File: src/server/api/routers/gbp.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
createGbpPostSchema.valid { practiceId, locationId, content, ctaType: "BOOK" } Passes validation src/__tests__/unit/schemas/gbp.test.ts
createGbpPostSchema.contentTooLong { content: "a".repeat(1501) } Zod error: max 1500 chars src/__tests__/unit/schemas/gbp.test.ts
createGbpPostSchema.tooManyMedia { mediaUrls: ["url1", ..., "url11"] } Zod error: max 10 URLs src/__tests__/unit/schemas/gbp.test.ts
createGbpPostSchema.invalidMediaUrl { mediaUrls: ["not-url"] } Zod error: invalid URL src/__tests__/unit/schemas/gbp.test.ts
createGbpPostSchema.invalidCta { ctaType: "INVALID" } Zod error: invalid enum src/__tests__/unit/schemas/gbp.test.ts
replyToReviewSchema.valid { reviewId, replyText: "Thank you!" } Passes validation src/__tests__/unit/schemas/gbp.test.ts
replyToReviewSchema.emptyReply { reviewId, replyText: "" } Zod error: min 1 char src/__tests__/unit/schemas/gbp.test.ts
replyToReviewSchema.replyTooLong { replyText: "a".repeat(2001) } Zod error: max 2000 chars src/__tests__/unit/schemas/gbp.test.ts

Integration Tests#

Test Setup Action Assertion File
gbp.getAuthUrl Practice with no GBP connection Call gbp.getAuthUrl Returns valid Google OAuth URL with scopes src/__tests__/integration/routers/gbp.test.ts
gbp.listAccounts Practice with 1 linked GBP account Call gbp.listAccounts Returns array with 1 account src/__tests__/integration/routers/gbp.test.ts
gbp.listAccounts.empty Practice with no GBP account Call gbp.listAccounts Returns empty array src/__tests__/integration/routers/gbp.test.ts
gbp.listLocations Mock GBP API with 2 locations Call gbp.listLocations Returns 2 locations from mock src/__tests__/integration/routers/gbp.test.ts
gbp.createPost Mock GBP API ready Call gbp.createPost with valid content Post persisted, GBP API called with correct payload src/__tests__/integration/routers/gbp.test.ts
gbp.createPost.gbpApiError Mock GBP API returns 500 Call gbp.createPost Throws INTERNAL_SERVER_ERROR (500) or handles gracefully src/__tests__/integration/routers/gbp.test.ts
gbp.listPosts 3 posts in DB for location Call gbp.listPosts Returns array of 3 posts src/__tests__/integration/routers/gbp.test.ts
gbp.listPosts.filtered 2 published, 1 draft Call with status: "PUBLISHED" Returns array of 2 posts src/__tests__/integration/routers/gbp.test.ts
gbp.deletePost Post exists in DB Call gbp.deletePost Post deleted from DB and GBP API src/__tests__/integration/routers/gbp.test.ts
gbp.listReviews Mock GBP API with 2 reviews Call gbp.listReviews Returns 2 reviews src/__tests__/integration/routers/gbp.test.ts
gbp.replyToReview Mock review exists Call gbp.replyToReview with text Reply sent to GBP API, review updated src/__tests__/integration/routers/gbp.test.ts
gbp.replyToReview.aiGenerate Mock review, generateAI: true Call with AI flag AI service called, generated reply sent to GBP src/__tests__/integration/routers/gbp.test.ts
gbp.getInsights Mock GBP API with insights data Call gbp.getInsights with date range Returns insights array src/__tests__/integration/routers/gbp.test.ts
gbp.listQA Mock GBP API with 2 QAs Call gbp.listQA Returns 2 QAs src/__tests__/integration/routers/gbp.test.ts
gbp.answerQuestion Mock QA exists Call gbp.answerQuestion with answer Answer sent to GBP API, QA updated src/__tests__/integration/routers/gbp.test.ts
gbp.uploadPhoto Mock GBP API ready Call gbp.uploadPhoto with URL Photo uploaded to GBP, record created src/__tests__/integration/routers/gbp.test.ts

E2E Tests#

Flow Steps Expected End State File
gbp.connectAndPost 1. Connect GBP 2. List locations 3. Create post 4. Verify post Post visible in GBP list e2e/gbp/connect-and-post.spec.ts

Success Criteria (Binary)#

  • gbp.getAuthUrl returns valid Google OAuth authorization URL
  • gbp.getAuthUrl includes required GBP API scopes
  • gbp.listAccounts returns only accounts linked to caller's practice
  • gbp.listLocations calls GBP API with stored access token
  • gbp.listLocations refreshes token if expired
  • gbp.createPost validates content max 1500 characters
  • gbp.createPost validates max 10 media URLs
  • gbp.createPost validates each media URL format
  • gbp.createPost validates ctaType against allowed enum values
  • gbp.createPost persists post to database before calling GBP API
  • gbp.createPost calls GBP API with correct location ID and payload
  • gbp.listPosts supports filtering by locationId and status
  • gbp.deletePost removes post from DB and GBP API
  • gbp.listReviews returns reviews from GBP API for practice locations
  • gbp.replyToReview validates reply text (1-2000 chars)
  • gbp.replyToReview with generateAI: true calls AI service before posting
  • gbp.getInsights returns GBP insights for date range
  • gbp.listQA returns Q&A for specified location
  • gbp.answerQuestion posts answer to GBP and persists locally
  • gbp.uploadPhoto uploads to GBP and stores reference URL

Agent Context (Pre-conditions)#

  • Required DB state: Practice with GBP account connection, GbpAccount with valid tokens
  • Required env vars: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GBP_API_KEY
  • Required external mocks: GBP API mock (locations, posts, reviews, insights, Q&A, photos), Google OAuth mock, AI service mock for generateAI

Verification Commands#

# Run unit tests for GBP schemas
pnpm test:unit -- src/__tests__/unit/schemas/gbp.test.ts

# Run integration tests for GBP router
pnpm test:integration -- src/__tests__/integration/routers/gbp.test.ts

# Run GBP connect-and-post E2E
pnpm test:e2e -- e2e/gbp/connect-and-post.spec.ts

6. Social Router#

Spec Reference: docs/specs/backend-api.md Section 6
File: src/server/api/routers/social.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
createSocialPostSchema.valid { socialAccountId, content: "Hello!" } Passes validation src/__tests__/unit/schemas/social.test.ts
createSocialPostSchema.contentTooLong { content: "a".repeat(5001) } Zod error: max 5000 chars src/__tests__/unit/schemas/social.test.ts
createSocialPostSchema.tooManyMedia { mediaUrls: Array(11).fill("url") } Zod error: max 10 URLs src/__tests__/unit/schemas/social.test.ts
createSocialPostSchema.invalidMediaType { mediaType: "INVALID" } Zod error: invalid enum src/__tests__/unit/schemas/social.test.ts
createSocialPostSchema.invalidLinkUrl { linkUrl: "not-url" } Zod error: invalid URL src/__tests__/unit/schemas/social.test.ts
schedulePostsSchema.valid { posts: [{ platform: "FACEBOOK", content: "Hi", scheduledFor: "2026-01-01T00:00:00Z" }] } Passes validation src/__tests__/unit/schemas/social.test.ts
schedulePostsSchema.invalidPlatform { posts: [{ platform: "TIKTOK" }] } Zod error: invalid platform enum src/__tests__/unit/schemas/social.test.ts
schedulePostsSchema.invalidDate { posts: [{ scheduledFor: "not-a-date" }] } Zod error: invalid datetime src/__tests__/unit/schemas/social.test.ts

Integration Tests#

Test Setup Action Assertion File
social.getAuthUrl Platform = "FACEBOOK" Call social.getAuthUrl Returns valid platform OAuth URL src/__tests__/integration/routers/social.test.ts
social.listAccounts Practice with 2 connected accounts Call social.listAccounts Returns 2 accounts src/__tests__/integration/routers/social.test.ts
social.disconnect Account exists Call social.disconnect with accountId Account marked inactive or deleted src/__tests__/integration/routers/social.test.ts
social.createPost Connected account ready Call social.createPost with content Post persisted to DB src/__tests__/integration/routers/social.test.ts
social.listPosts 3 posts in DB Call social.listPosts Returns 3 posts src/__tests__/integration/routers/social.test.ts
social.listPosts.filtered 2 for account1, 1 for account2 Call with accountId Returns filtered posts src/__tests__/integration/routers/social.test.ts
social.deletePost Post exists Call social.deletePost Post removed from DB and platform API src/__tests__/integration/routers/social.test.ts
social.schedulePosts Multiple posts with future dates Call social.schedulePosts Posts created with SCHEDULED status, job queued src/__tests__/integration/routers/social.test.ts
social.schedulePosts.platformApi Mock platform API ready Call with valid posts Each platform API called with correct payload src/__tests__/integration/routers/social.test.ts

E2E Tests#

Flow Steps Expected End State File
social.connectAndSchedule 1. Connect Facebook 2. Create post 3. Schedule post Post appears in scheduled list e2e/social/connect-and-schedule.spec.ts

Success Criteria (Binary)#

  • social.getAuthUrl returns valid OAuth URL for requested platform
  • social.getAuthUrl supports FACEBOOK, INSTAGRAM, LINKEDIN, TWITTER
  • social.listAccounts returns only accounts for caller's practice
  • social.disconnect revokes tokens and marks account inactive
  • social.createPost validates content (1-5000 chars)
  • social.createPost validates max 10 media URLs
  • social.createPost validates mediaType against allowed enum
  • social.createPost validates linkUrl as URL when provided
  • social.listPosts supports filtering by accountId and status
  • social.deletePost removes post from DB and platform API
  • social.schedulePosts validates each post has valid platform enum
  • social.schedulePosts validates each post has valid datetime
  • social.schedulePosts creates posts with status: "SCHEDULED"
  • social.schedulePosts queues BullMQ job for each scheduled post
  • social.schedulePosts returns array of scheduled IDs

Agent Context (Pre-conditions)#

  • Required DB state: Practice with SocialAccount records for 1+ platforms
  • Required env vars: FACEBOOK_APP_ID, INSTAGRAM_APP_ID, LINKEDIN_CLIENT_ID, TWITTER_CLIENT_ID
  • Required external mocks: Platform OAuth mocks (Facebook, Instagram, LinkedIn, Twitter), Platform API mocks for post creation/deletion

Verification Commands#

# Run unit tests for social schemas
pnpm test:unit -- src/__tests__/unit/schemas/social.test.ts

# Run integration tests for social router
pnpm test:integration -- src/__tests__/integration/routers/social.test.ts

# Run social connect-and-schedule E2E
pnpm test:e2e -- e2e/social/connect-and-schedule.spec.ts

7. Content Router#

Spec Reference: docs/specs/backend-api.md Section 7
File: src/server/api/routers/content.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
generateContentSchema.valid { taskType: "GBP_POST", variables: {} } Passes validation src/__tests__/unit/schemas/content.test.ts
generateContentSchema.invalidTaskType { taskType: "INVALID_TASK" } Zod error: invalid enum src/__tests__/unit/schemas/content.test.ts
generateContentSchema.validModel { model: "claude-sonnet" } Passes validation src/__tests__/unit/schemas/content.test.ts
generateContentSchema.invalidModel { model: "gpt-3" } Zod error: invalid model enum src/__tests__/unit/schemas/content.test.ts
updateContentSchema.valid { id: "cnt_123", title: "New Title" } Passes validation src/__tests__/unit/schemas/content.test.ts
updateContentSchema.seoFields { id: "cnt_123", seoTitle: "Title", seoDescription: "Desc", focusKeywords: ["kw1"] } Passes validation src/__tests__/unit/schemas/content.test.ts

Integration Tests#

Test Setup Action Assertion File
content.generate.success Mock AI service ready Call content.generate with taskType: "GBP_POST" ContentPiece created with status "PENDING" or "DRAFT" src/__tests__/integration/routers/content.test.ts
content.generate.aiService Mock AI service returns content Call with valid variables AI service called with correct prompt template src/__tests__/integration/routers/content.test.ts
content.generate.rateLimit 5 calls in last minute 6th call to content.generate Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/routers/content.test.ts
content.list 3 content pieces in practice Call content.list Returns 3 content pieces src/__tests__/integration/routers/content.test.ts
content.list.filteredByStatus 2 DRAFT, 1 APPROVED Call with status: "DRAFT" Returns 2 DRAFT pieces src/__tests__/integration/routers/content.test.ts
content.list.filteredByType 2 GBP_POST, 1 BLOG_POST Call with type: "GBP_POST" Returns 2 GBP_POST pieces src/__tests__/integration/routers/content.test.ts
content.get Content piece exists Call content.get with ID Returns content piece with all fields src/__tests__/integration/routers/content.test.ts
content.update Content piece exists Call content.update with title change DB updated, returns updated piece src/__tests__/integration/routers/content.test.ts
content.approve Content in "PENDING" status Call content.approve Status changed to "APPROVED" src/__tests__/integration/routers/content.test.ts
content.reject Content in "PENDING" status Call content.reject with reason Status changed to "REJECTED", reason stored src/__tests__/integration/routers/content.test.ts
content.delete Content piece exists Call content.delete Content removed from DB src/__tests__/integration/routers/content.test.ts
content.delete.published Content in "PUBLISHED" status Call content.delete Throws CONFLICT or requires unpublish first src/__tests__/integration/routers/content.test.ts

E2E Tests#

Flow Steps Expected End State File
content.generateAndApprove 1. Generate content 2. Review 3. Approve 4. Publish Content status = PUBLISHED e2e/content/generate-and-approve.spec.ts

Success Criteria (Binary)#

  • content.generate validates taskType against 10 allowed enum values
  • content.generate validates model against 4 allowed enum values (claude-sonnet, claude-haiku, gpt-4o, gpt-4o-mini)
  • content.generate defaults variables to {}
  • content.generate calls AI service with correct prompt template for taskType
  • content.generate persists ContentPiece with cost tracking
  • content.generate rate limit: 5 per minute
  • content.list supports filtering by status and type
  • content.list returns only content for caller's practice
  • content.get returns 404 for non-existent content ID
  • content.get returns 403 for content outside caller's practice
  • content.update allows partial updates (title, content, seoTitle, seoDescription, focusKeywords)
  • content.approve changes status from PENDING → APPROVED
  • content.approve triggers publish if auto-publish configured
  • content.reject changes status from PENDING → REJECTED
  • content.reject stores optional reason
  • content.delete prevents deleting PUBLISHED content without unpublish
  • content.delete removes content from database

Agent Context (Pre-conditions)#

  • Required DB state: Practice with ContentPiece records, PromptTemplate for each taskType
  • Required env vars: ANTHROPIC_API_KEY, OPENAI_API_KEY
  • Required external mocks: Claude API mock, GPT-4o API mock

Verification Commands#

# Run unit tests for content schemas
pnpm test:unit -- src/__tests__/unit/schemas/content.test.ts

# Run integration tests for content router
pnpm test:integration -- src/__tests__/integration/routers/content.test.ts

# Run content generate-and-approve E2E
pnpm test:e2e -- e2e/content/generate-and-approve.spec.ts

8. Citation Router#

Spec Reference: docs/specs/backend-api.md Section 8
File: src/server/api/routers/citation.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
submitCitationsSchema.valid { locationId: "loc_123", directoryNames: ["justdial", "practo"] } Passes validation src/__tests__/unit/schemas/citation.test.ts
submitCitationsSchema.emptyDirectories { directoryNames: [] } Zod error: min 1 directory src/__tests__/unit/schemas/citation.test.ts
submitCitationsSchema.tooManyDirectories { directoryNames: Array(51).fill("dir") } Zod error: max 50 directories src/__tests__/unit/schemas/citation.test.ts
citationActionSchema.valid { citationId: "cit_123" } Passes validation src/__tests__/unit/schemas/citation.test.ts

Integration Tests#

Test Setup Action Assertion File
citation.listDirectories 5 directories in DB Call citation.listDirectories Returns 5 directories src/__tests__/integration/routers/citation.test.ts
citation.list 3 citations for location Call citation.list with locationId Returns 3 citations src/__tests__/integration/routers/citation.test.ts
citation.submit Location with valid NAP Call citation.submit with 2 directories Job created, returns { jobId } src/__tests__/integration/routers/citation.test.ts
citation.submit.invalidDirectory Location valid Call with non-existent directory name Returns error or skips invalid directory src/__tests__/integration/routers/citation.test.ts
citation.verifyNap Citation with pending status Call citation.verifyNap Job created for NAP verification src/__tests__/integration/routers/citation.test.ts
citation.delete Citation exists Call citation.delete Job created for deletion, citation marked deleting src/__tests__/integration/routers/citation.test.ts
citation.getSnapshot Citation with snapshot data Call citation.getSnapshot Returns citation with NAP snapshot src/__tests__/integration/routers/citation.test.ts
citation.getSnapshot.notFound Non-existent citation Call citation.getSnapshot Throws NOT_FOUND (404) src/__tests__/integration/routers/citation.test.ts

E2E Tests#

Flow Steps Expected End State File
citation.submitAndVerify 1. List directories 2. Submit to 2 dirs 3. Verify NAP Citations show SUBMITTED/VERIFIED e2e/citation/submit-and-verify.spec.ts

Success Criteria (Binary)#

  • citation.listDirectories returns all available citation directories
  • citation.list returns citations for specified location only
  • citation.list returns 403 for location outside caller's practice
  • citation.submit validates at least 1 directory name
  • citation.submit validates max 50 directory names
  • citation.submit creates BullMQ job for each directory submission
  • citation.submit returns jobId for tracking
  • citation.submit handles invalid directory names gracefully
  • citation.verifyNap creates NAP verification job
  • citation.verifyNap updates citation status to VERIFIED on success
  • citation.delete creates deletion job
  • citation.delete marks citation as pending deletion
  • citation.getSnapshot returns full citation with NAP snapshot data
  • citation.getSnapshot returns 404 for non-existent citation ID

Agent Context (Pre-conditions)#

  • Required DB state: Practice with Location, CitationDirectory seed data, Citation records
  • Required env vars: FIRECRAWL_API_KEY (for NAP verification mock)
  • Required external mocks: Firecrawl API mock (NAP matching), Directory submission API mocks (Justdial, Practo, etc.)

Verification Commands#

# Run unit tests for citation schemas
pnpm test:unit -- src/__tests__/unit/schemas/citation.test.ts

# Run integration tests for citation router
pnpm test:integration -- src/__tests__/integration/routers/citation.test.ts

# Run citation submit-and-verify E2E
pnpm test:e2e -- e2e/citation/submit-and-verify.spec.ts

9. DirectoryProfile Router#

Spec Reference: docs/specs/backend-api.md Section 9
File: src/server/api/routers/directory-profile.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
updateSectionSchema.valid { sectionKey: "hero", content: "Welcome!" } Passes validation src/__tests__/unit/schemas/site.test.ts
updateSectionSchema.invalidKey { sectionKey: "invalid" } Zod error: invalid enum src/__tests__/unit/schemas/site.test.ts
updateSectionSchema.contentTooLong { content: "a".repeat(50001) } Zod error: max 50000 chars src/__tests__/unit/schemas/site.test.ts
updateSectionSchema.tooManyMedia { mediaUrls: Array(21).fill("url") } Zod error: max 20 URLs src/__tests__/unit/schemas/site.test.ts
setTemplateSchema.valid { templateId: "medical-modern" } Passes validation src/__tests__/unit/schemas/site.test.ts
setTemplateSchema.invalid { templateId: "invalid-template" } Zod error: invalid enum src/__tests__/unit/schemas/site.test.ts

Integration Tests#

Test Setup Action Assertion File
directoryProfile.get Practice with profile sections Call directoryProfile.get Returns practice + all profile sections src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.updateSection Practice with "hero" section Call directoryProfile.updateSection with new content Section updated in DB, returns updated section src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.setVisibility Section visible = true Call directoryProfile.setVisibility with isVisible: false Section visibility toggled src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.reorderSections 3 sections: [hero, about, services] Call with [about, hero, services] DB updated with new order, returns reordered sections src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.reorderSections.invalid 3 sections Call with [hero, about] (missing 1) Throws BAD_REQUEST (400) src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.setTemplate Practice on "medical-modern" Call with templateId: "dental-clean" Practice template updated src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.publish Practice with valid sections Call directoryProfile.publish Site deployed, returns published URL src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.publish.noPrimaryDomain Practice with no directory profile URL Call directoryProfile.publish Returns directorySlug URL (e.g., practice.rankflow.site) src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.unpublish Published profile Call directoryProfile.unpublish Site taken down, returns { success: true } src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.setDirectoryUrl Practice with domain "example.com" Call directoryProfile.setDirectoryUrl Returns directory profile paths for verification src/__tests__/integration/routers/directory-profile.test.ts
directoryProfile.regenerate Practice with AI-generated sections Call directoryProfile.regenerate Job created for AI regeneration, returns { jobId } src/__tests__/integration/routers/directory-profile.test.ts

E2E Tests#

Flow Steps Expected End State File
site.buildAndPublish 1. Update sections 2. Set template 3. Publish 4. Visit URL Site live at public URL e2e/profile/profile-publish.spec.ts

Success Criteria (Binary)#

  • directoryProfile.get returns practice data plus all 12 profile sections
  • directoryProfile.updateSection validates sectionKey against 12 allowed enum values
  • directoryProfile.updateSection validates content max 50000 characters
  • directoryProfile.updateSection validates max 20 media URLs
  • directoryProfile.updateSection validates each media URL format
  • directoryProfile.setVisibility toggles isVisible boolean on section
  • directoryProfile.reorderSections validates all 12 section keys are present
  • directoryProfile.reorderSections validates no duplicate section keys
  • directoryProfile.reorderSections updates order index in database
  • directoryProfile.setTemplate validates templateId against 5 allowed templates
  • directoryProfile.publish generates static site or deploys to CDN
  • directoryProfile.publish returns accessible public URL
  • directoryProfile.publish includes directorySlug if no directory profile URL set
  • directoryProfile.unpublish removes site from public access
  • directoryProfile.setDirectoryUrl returns required directory profile paths (A, directory path)
  • directoryProfile.setDirectoryUrl validates domain format
  • directoryProfile.regenerate creates AI content generation job
  • directoryProfile.regenerate returns jobId for tracking

Agent Context (Pre-conditions)#

  • Required DB state: Practice with DirectoryProfileSection records for all 12 sections, DirectoryProfileTemplate seed data
  • Required env vars: VERCEL_TOKEN or NETLIFY_TOKEN (for deploy mock), ANTHROPIC_API_KEY (for regeneration)
  • Required external mocks: Vercel/Netlify deploy API mock, AI service mock for regeneration

Verification Commands#

# Run unit tests for site schemas
pnpm test:unit -- src/__tests__/unit/schemas/site.test.ts

# Run integration tests for site router
pnpm test:integration -- src/__tests__/integration/routers/directory-profile.test.ts

# Run site build-and-publish E2E
pnpm test:e2e -- e2e/profile/profile-publish.spec.ts

10. Report Router#

Spec Reference: docs/specs/backend-api.md Section 10
File: src/server/api/routers/report.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
generateReportSchema.valid { periodStart: "2026-01-01T00:00:00Z", periodEnd: "2026-01-31T23:59:59Z" } Passes validation src/__tests__/unit/schemas/report.test.ts
generateReportSchema.invalidStart { periodStart: "not-a-date" } Zod error: invalid datetime src/__tests__/unit/schemas/report.test.ts
generateReportSchema.endBeforeStart { periodStart: "2026-02-01", periodEnd: "2026-01-01" } Zod error: end must be after start src/__tests__/unit/schemas/report.test.ts
emailReportSchema.valid { id: "rep_123", recipients: ["a@b.com", "c@d.com"] } Passes validation src/__tests__/unit/schemas/report.test.ts
emailReportSchema.invalidEmail { recipients: ["not-email"] } Zod error: invalid email src/__tests__/unit/schemas/report.test.ts
emailReportSchema.tooManyRecipients { recipients: Array(11).fill("a@b.com") } Zod error: max 10 recipients src/__tests__/unit/schemas/report.test.ts
emailReportSchema.emptyRecipients { recipients: [] } Zod error: min 1 recipient src/__tests__/unit/schemas/report.test.ts

Integration Tests#

Test Setup Action Assertion File
report.list 3 reports in practice Call report.list Returns array of 3 reports src/__tests__/integration/routers/report.test.ts
report.get Report exists Call report.get with ID Returns report with data and pdfUrl src/__tests__/integration/routers/report.test.ts
report.get.notFound Non-existent report Call report.get Throws NOT_FOUND (404) src/__tests__/integration/routers/report.test.ts
report.generate Valid date range Call report.generate Job created, returns { jobId } src/__tests__/integration/routers/report.test.ts
report.generate.periodValidation periodEnd before periodStart Call with invalid range Throws BAD_REQUEST (400) src/__tests__/integration/routers/report.test.ts
report.downloadPdf Report with pdfUrl Call report.downloadPdf Returns signed URL or direct download src/__tests__/integration/routers/report.test.ts
report.downloadPdf.notReady Report in "PENDING" status Call report.downloadPdf Throws BAD_REQUEST or returns 202 src/__tests__/integration/routers/report.test.ts
report.email Report ready, 2 valid emails Call report.email with recipients Emails sent via Resend mock, returns { success: true } src/__tests__/integration/routers/report.test.ts
report.email.resendFailure Mock Resend returns 500 Call report.email Returns { success: false } or throws src/__tests__/integration/routers/report.test.ts

E2E Tests#

Flow Steps Expected End State File
report.generateAndDownload 1. Generate report 2. Wait for job 3. Download PDF PDF file downloaded successfully e2e/report/generate-and-download.spec.ts

Success Criteria (Binary)#

  • report.list returns only reports for caller's practice
  • report.get returns 404 for non-existent report ID
  • report.get returns 403 for report outside caller's practice
  • report.generate validates periodStart and periodEnd as valid ISO datetimes
  • report.generate validates periodEnd is after periodStart
  • report.generate creates report generation job (BullMQ)
  • report.generate returns jobId for tracking
  • report.downloadPdf returns accessible URL for ready reports
  • report.downloadPdf returns error for reports not in READY status
  • report.email validates at least 1 recipient
  • report.email validates max 10 recipients
  • report.email validates each recipient as email format
  • report.email sends report PDF via Resend
  • report.email returns { success: true } on send success
  • report.email handles Resend failure gracefully

Agent Context (Pre-conditions)#

  • Required DB state: Practice with Report records, Job records for report generation
  • Required env vars: RESEND_API_KEY, PDF_GENERATION_SERVICE_URL
  • Required external mocks: Resend API mock, PDF generation service mock, BullMQ job processor mock

Verification Commands#

# Run unit tests for report schemas
pnpm test:unit -- src/__tests__/unit/schemas/report.test.ts

# Run integration tests for report router
pnpm test:integration -- src/__tests__/integration/routers/report.test.ts

# Run report generate-and-download E2E
pnpm test:e2e -- e2e/report/generate-and-download.spec.ts

11. Lead Router#

Spec Reference: docs/specs/backend-api.md Section 11
File: src/server/api/routers/lead.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
updateLeadStatusSchema.valid { id: "ld_123", status: "CONVERTED" } Passes validation src/__tests__/unit/schemas/lead.test.ts
updateLeadStatusSchema.invalidStatus { status: "INVALID" } Zod error: invalid enum src/__tests__/unit/schemas/lead.test.ts
updateLeadStatusSchema.missingId { status: "NEW" } Zod error: required id src/__tests__/unit/schemas/lead.test.ts

Integration Tests#

Test Setup Action Assertion File
lead.list 4 leads in practice (2 NEW, 1 CONTACTED, 1 CONVERTED) Call lead.list Returns 4 leads src/__tests__/integration/routers/lead.test.ts
lead.list.filtered 4 leads with mixed statuses Call with status: "NEW" Returns 2 NEW leads src/__tests__/integration/routers/lead.test.ts
lead.get Lead exists Call lead.get with ID Returns lead with all fields src/__tests__/integration/routers/lead.test.ts
lead.get.wrongPractice Lead belongs to other practice Call lead.get Throws FORBIDDEN (403) src/__tests__/integration/routers/lead.test.ts
lead.updateStatus Lead in "NEW" status Call lead.updateStatus with "CONTACTED" Status updated, returns updated lead src/__tests__/integration/routers/lead.test.ts
lead.updateStatus.invalid Lead exists Call with status "INVALID" Throws BAD_REQUEST (400) src/__tests__/integration/routers/lead.test.ts
lead.delete Lead exists Call lead.delete Lead removed from DB src/__tests__/integration/routers/lead.test.ts
lead.delete.notFound Non-existent lead Call lead.delete Throws NOT_FOUND (404) src/__tests__/integration/routers/lead.test.ts

E2E Tests#

Flow Steps Expected End State File
lead.managePipeline 1. List leads 2. Update status to CONTACTED 3. Convert lead Lead status = CONVERTED e2e/lead/manage-pipeline.spec.ts

Success Criteria (Binary)#

  • lead.list returns all leads for caller's practice
  • lead.list supports filtering by status (NEW, CONTACTED, CONVERTED, LOST)
  • lead.get returns 404 for non-existent lead ID
  • lead.get returns 403 for lead outside caller's practice
  • lead.updateStatus validates status against 4 allowed enum values
  • lead.updateStatus persists status change to database
  • lead.updateStatus returns updated lead object
  • lead.delete removes lead from database
  • lead.delete returns 404 for non-existent lead
  • lead.delete returns 403 for lead outside caller's practice

Agent Context (Pre-conditions)#

  • Required DB state: Practice with Lead records (4+ leads with mixed statuses)
  • Required env vars: None
  • Required external mocks: None

Verification Commands#

# Run unit tests for lead schemas
pnpm test:unit -- src/__tests__/unit/schemas/lead.test.ts

# Run integration tests for lead router
pnpm test:integration -- src/__tests__/integration/routers/lead.test.ts

# Run lead manage-pipeline E2E
pnpm test:e2e -- e2e/lead/manage-pipeline.spec.ts

12. Billing Router#

Spec Reference: docs/specs/backend-api.md Section 12
File: src/server/api/routers/billing.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
createSubscriptionSchema.valid { plan: "STANDARD", paymentMethod: "stripe" } Passes validation src/__tests__/unit/schemas/billing.test.ts
createSubscriptionSchema.invalidPlan { plan: "FREE" } Zod error: invalid enum src/__tests__/unit/schemas/billing.test.ts
createSubscriptionSchema.invalidMethod { paymentMethod: "paypal" } Zod error: invalid enum src/__tests__/unit/schemas/billing.test.ts
createSubscriptionSchema.lowercasePlan { plan: "starter" } Zod error: invalid enum (case sensitive) src/__tests__/unit/schemas/billing.test.ts

Integration Tests#

Test Setup Action Assertion File
billing.getPlan Practice on STANDARD plan Call billing.getPlan Returns { tier: "STANDARD", features: [...] } src/__tests__/integration/routers/billing.test.ts
billing.getPlan.trial Practice in trial mode Call billing.getPlan Returns trial info with expiry date src/__tests__/integration/routers/billing.test.ts
billing.createSubscription.stripe Mock Stripe ready Call with plan: "PREMIUM", paymentMethod: "stripe" Stripe subscription created, returns clientSecret src/__tests__/integration/routers/billing.test.ts
billing.createSubscription.razorpay Mock Razorpay ready Call with plan: "STANDARD", paymentMethod: "razorpay" Razorpay subscription created, returns clientSecret src/__tests__/integration/routers/billing.test.ts
billing.createSubscription.cardDeclined Mock Stripe returns card_declined Call with valid plan Returns error or handles gracefully src/__tests__/integration/routers/billing.test.ts
billing.cancel Active subscription Call billing.cancel Subscription cancelled, returns { success, effectiveDate } src/__tests__/integration/routers/billing.test.ts
billing.cancel.alreadyCancelled Cancelled subscription Call billing.cancel Returns error or { success: false } src/__tests__/integration/routers/billing.test.ts
billing.listInvoices 3 invoices for practice Call billing.listInvoices Returns 3 invoices src/__tests__/integration/routers/billing.test.ts
billing.getInvoice Invoice exists Call billing.getInvoice with ID Returns invoice with line items src/__tests__/integration/routers/billing.test.ts
billing.updatePaymentMethod Mock Stripe with new payment method Call billing.updatePaymentMethod with methodId Payment method updated, returns { success: true } src/__tests__/integration/routers/billing.test.ts
billing.updatePaymentMethod.invalid Invalid payment method ID Call with invalid ID Returns { success: false } src/__tests__/integration/routers/billing.test.ts

E2E Tests#

Flow Steps Expected End State File
billing.subscribeAndCancel 1. Get plan 2. Create subscription 3. Cancel Subscription status = cancelled e2e/billing/subscribe-and-cancel.spec.ts

Success Criteria (Binary)#

  • billing.getPlan returns current tier and feature list for practice
  • billing.getPlan returns trial information when in trial period
  • billing.createSubscription validates plan against 4 enum values (STARTER, STANDARD, PREMIUM, ENTERPRISE)
  • billing.createSubscription validates payment method as "stripe" or "razorpay"
  • billing.createSubscription creates subscription with correct payment provider
  • billing.createSubscription returns clientSecret for frontend confirmation
  • billing.createSubscription handles card declined error from Stripe
  • billing.createSubscription handles Razorpay payment failure
  • billing.cancel sets subscription status to cancelled
  • billing.cancel returns effective cancellation date
  • billing.cancel handles already-cancelled subscription gracefully
  • billing.listInvoices returns invoices for caller's practice only
  • billing.getInvoice returns 404 for non-existent invoice
  • billing.updatePaymentMethod updates payment method with provider
  • billing.updatePaymentMethod returns { success: true } on success
  • billing.updatePaymentMethod handles invalid payment method ID

Agent Context (Pre-conditions)#

  • Required DB state: Practice with Billing record, Invoice records (3+), Subscription record
  • Required env vars: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET
  • Required external mocks: Stripe API mock (subscriptions, payment methods, invoices), Razorpay API mock

Verification Commands#

# Run unit tests for billing schemas
pnpm test:unit -- src/__tests__/unit/schemas/billing.test.ts

# Run integration tests for billing router
pnpm test:integration -- src/__tests__/integration/routers/billing.test.ts

# Run billing subscribe-and-cancel E2E
pnpm test:e2e -- e2e/billing/subscribe-and-cancel.spec.ts

13. Skill Router#

Spec Reference: docs/specs/backend-api.md Section 13
File: src/server/api/routers/skill.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
executeSkillSchema.valid { skillId: "sk_123", payload: {}, async: false } Passes validation src/__tests__/unit/schemas/skill.test.ts
executeSkillSchema.defaultAsync { skillId: "sk_123" } async defaults to false src/__tests__/unit/schemas/skill.test.ts
executeSkillSchema.defaultPayload { skillId: "sk_123" } payload defaults to {} src/__tests__/unit/schemas/skill.test.ts
executeAdminSchema.valid { skillId: "sk_123", practiceId: "prac_123", payload: {} } Passes validation src/__tests__/unit/schemas/skill.test.ts
executeAdminSchema.missingPracticeId { skillId: "sk_123" } Zod error: required practiceId src/__tests__/unit/schemas/skill.test.ts

Integration Tests#

Test Setup Action Assertion File
skill.list 5 skills in DB Call skill.list Returns 5 skill summaries src/__tests__/integration/routers/skill.test.ts
skill.list.protected No auth Call skill.list without session Throws UNAUTHORIZED (401) src/__tests__/integration/routers/skill.test.ts
skill.get Skill exists Call skill.get with ID Returns full skill definition src/__tests__/integration/routers/skill.test.ts
skill.get.notFound Non-existent skill Call skill.get Throws NOT_FOUND (404) src/__tests__/integration/routers/skill.test.ts
skill.execute.success Mock skill processor ready Call skill.execute with valid skillId Returns ExecutionResult with status "SUCCESS" src/__tests__/integration/routers/skill.test.ts
skill.execute.async Mock skill processor ready Call skill.execute with async: true Returns ExecutionResult with status "QUEUED" and jobId src/__tests__/integration/routers/skill.test.ts
skill.execute.rateLimit 10 calls in last minute 11th call to skill.execute Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/routers/skill.test.ts
skill.execute.invalidSkill Non-existent skillId Call skill.execute Throws NOT_FOUND (404) src/__tests__/integration/routers/skill.test.ts
skill.executeAdmin Admin user, mock skill ready Call skill.executeAdmin with practiceId Returns ExecutionResult with status "SUCCESS" src/__tests__/integration/routers/skill.test.ts
skill.executeAdmin.nonAdmin Non-admin user Call skill.executeAdmin Throws FORBIDDEN (403) src/__tests__/integration/routers/skill.test.ts
skill.getStatus Job in "RUNNING" status Call skill.getStatus with jobId Returns ExecutionResult with current status src/__tests__/integration/routers/skill.test.ts
skill.getStatus.completed Job completed with data Call skill.getStatus Returns ExecutionResult with status "SUCCESS" and data src/__tests__/integration/routers/skill.test.ts
skill.getStatus.notFound Non-existent jobId Call skill.getStatus Throws NOT_FOUND (404) src/__tests__/integration/routers/skill.test.ts

E2E Tests#

Flow Steps Expected End State File
skill.executeAndCheckStatus 1. Execute async skill 2. Poll status 3. Verify completion Status transitions from QUEUED → SUCCESS e2e/skill/execute-and-check.spec.ts

Success Criteria (Binary)#

  • skill.list requires authentication (protectedProcedure)
  • skill.list returns skill summaries for all available skills
  • skill.get returns full skill definition including parameters
  • skill.get returns 404 for non-existent skill ID
  • skill.execute validates skillId exists
  • skill.execute defaults payload to {}
  • skill.execute defaults async to false
  • skill.execute synchronous mode returns ExecutionResult immediately
  • skill.execute async mode returns ExecutionResult with status "QUEUED" and jobId
  • skill.execute rate limit: 10 per minute
  • skill.execute requires practiceProcedure (practice context + CLIENT+ role)
  • skill.executeAdmin requires adminProcedure (ADMIN role only)
  • skill.executeAdmin validates practiceId exists
  • skill.executeAdmin executes skill on behalf of specified practice
  • skill.getStatus returns current execution status for jobId
  • skill.getStatus returns full result data when job is complete
  • skill.getStatus returns 404 for non-existent jobId
  • skill.getStatus requires protectedProcedure (any authenticated user)

Agent Context (Pre-conditions)#

  • Required DB state: Skill table with 5+ skill definitions, Job table with job records
  • Required env vars: SKILL_EXECUTION_TIMEOUT_MS (default: 30000)
  • Required external mocks: Skill execution engine mock (Composio or internal)

Verification Commands#

# Run unit tests for skill schemas
pnpm test:unit -- src/__tests__/unit/schemas/skill.test.ts

# Run integration tests for skill router
pnpm test:integration -- src/__tests__/integration/routers/skill.test.ts

# Run skill execute-and-check E2E
pnpm test:e2e -- e2e/skill/execute-and-check.spec.ts

14. Admin Router#

Spec Reference: docs/specs/backend-api.md Section 14
File: src/server/api/routers/admin.ts

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
updateClientSchema.valid { id: "prac_123", status: "ACTIVE", tier: "PREMIUM" } Passes validation src/__tests__/unit/schemas/admin.test.ts
updateClientSchema.invalidStatus { status: "DELETED" } Zod error: invalid enum src/__tests__/unit/schemas/admin.test.ts
updateClientSchema.invalidTier { tier: "BASIC" } Zod error: invalid enum src/__tests__/unit/schemas/admin.test.ts
updateClientSchema.datetime { trialEndsAt: "2026-12-31T23:59:59Z" } Passes validation src/__tests__/unit/schemas/admin.test.ts
updateClientSchema.invalidDate { trialEndsAt: "not-a-date" } Zod error: invalid datetime src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.valid { taskType: "GBP_POST", systemPrompt: "...", userPromptTemplate: "..." } Passes validation src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.systemTooLong { systemPrompt: "a".repeat(10001) } Zod error: max 10000 chars src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.templateTooLong { userPromptTemplate: "a".repeat(20001) } Zod error: max 20000 chars src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.modelConfig { modelConfig: { provider: "anthropic", model: "claude-sonnet", temperature: 0.7, maxTokens: 2000 } } Passes validation src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.invalidProvider { modelConfig: { provider: "google" } } Zod error: invalid provider enum src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.tempOutOfRange { modelConfig: { temperature: 3 } } Zod error: max 2 src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.tokensTooLow { modelConfig: { maxTokens: 50 } } Zod error: min 100 src/__tests__/unit/schemas/admin.test.ts
updatePromptSchema.tokensTooHigh { modelConfig: { maxTokens: 9000 } } Zod error: max 8000 src/__tests__/unit/schemas/admin.test.ts

Integration Tests#

Test Setup Action Assertion File
admin.getKPIs 5 practices, 10 jobs, revenue data Call admin.getKPIs Returns KPIs with counts and revenue src/__tests__/integration/routers/admin.test.ts
admin.getKPIs.nonAdmin Non-admin user Call admin.getKPIs Throws FORBIDDEN (403) src/__tests__/integration/routers/admin.test.ts
admin.listClients 10 practices in DB Call admin.listClients Returns 10 practices src/__tests__/integration/routers/admin.test.ts
admin.listClients.filtered 5 ACTIVE, 5 TRIAL Call with status: "ACTIVE" Returns 5 ACTIVE practices src/__tests__/integration/routers/admin.test.ts
admin.listClients.tierFilter 3 STARTER, 3 PREMIUM Call with tier: "PREMIUM" Returns 3 PREMIUM practices src/__tests__/integration/routers/admin.test.ts
admin.getClient Practice with locations, members, content Call admin.getClient with ID Returns practice with all relations src/__tests__/integration/routers/admin.test.ts
admin.updateClient Practice in TRIAL Call admin.updateClient with status "ACTIVE" DB updated, returns updated practice src/__tests__/integration/routers/admin.test.ts
admin.updateClient.invalidStatus Practice exists Call with invalid status Throws BAD_REQUEST (400) src/__tests__/integration/routers/admin.test.ts
admin.listJobs 20 jobs in DB Call admin.listJobs Returns 20 jobs src/__tests__/integration/routers/admin.test.ts
admin.listJobs.filtered 10 RUNNING, 10 COMPLETED Call with status: "RUNNING" Returns 10 RUNNING jobs src/__tests__/integration/routers/admin.test.ts
admin.listJobs.practiceFilter Jobs across 3 practices Call with practiceId Returns jobs for that practice only src/__tests__/integration/routers/admin.test.ts
admin.retryJob Job in FAILED status Call admin.retryJob with jobId Job status reset to PENDING, attempts reset src/__tests__/integration/routers/admin.test.ts
admin.retryJob.notFailed Job in RUNNING status Call admin.retryJob Returns error or no-op src/__tests__/integration/routers/admin.test.ts
admin.listDomains 3 directory profile URLs Call admin.listDomains Returns domain status array src/__tests__/integration/routers/admin.test.ts
admin.listSocialConnections 5 social accounts Call admin.listSocialConnections Returns all social accounts src/__tests__/integration/routers/admin.test.ts
admin.listContent 50 content pieces Call admin.listContent Returns content array src/__tests__/integration/routers/admin.test.ts
admin.listContent.filtered 25 PENDING, 25 APPROVED Call with status: "PENDING" Returns 25 PENDING src/__tests__/integration/routers/admin.test.ts
admin.approveContent Content in PENDING status Call admin.approveContent with contentId Status changed to APPROVED src/__tests__/integration/routers/admin.test.ts
admin.updatePrompt Existing prompt template Call admin.updatePrompt with new system prompt Prompt updated, returns updated template src/__tests__/integration/routers/admin.test.ts
admin.getRevenue Revenue data for period Call admin.getRevenue with period: "2026-01" Returns revenue breakdown src/__tests__/integration/routers/admin.test.ts
admin.getSystemHealth All services healthy Call admin.getSystemHealth Returns { status: "healthy", checks: [...] } src/__tests__/integration/routers/admin.test.ts
admin.getSystemHealth.degraded DB connection failing Call admin.getSystemHealth Returns { status: "degraded", failedChecks: [...] } src/__tests__/integration/routers/admin.test.ts

E2E Tests#

Flow Steps Expected End State File
admin.dashboardOverview 1. Login as admin 2. View KPIs 3. List clients 4. Check health All admin endpoints return 200 e2e/admin/dashboard.spec.ts

Success Criteria (Binary)#

  • admin.getKPIs requires adminProcedure (ADMIN role only)
  • admin.getKPIs returns aggregate counts (practices, jobs, revenue, active users)
  • admin.listClients supports filtering by status (TRIAL, ACTIVE, PAST_DUE, SUSPENDED, CANCELLED, EXPIRED)
  • admin.listClients supports filtering by tier (STARTER, STANDARD, PREMIUM, ENTERPRISE)
  • admin.getClient returns practice with all relations (locations, members, content, jobs)
  • admin.getClient returns 404 for non-existent practice ID
  • admin.updateClient validates status against 6 enum values
  • admin.updateClient validates tier against 4 enum values
  • admin.updateClient validates trialEndsAt and subscriptionEndsAt as ISO datetime
  • admin.updateClient persists changes to database
  • admin.listJobs supports filtering by status and practiceId
  • admin.retryJob resets FAILED job to PENDING status
  • admin.retryJob resets attempt count
  • admin.retryJob returns error for non-FAILED jobs
  • admin.listDomains returns all directory profile URL registrations with profile status
  • admin.listSocialConnections returns all social accounts across all practices
  • admin.listContent supports filtering by status
  • admin.approveContent changes content status to APPROVED
  • admin.updatePrompt validates systemPrompt max 10000 chars
  • admin.updatePrompt validates userPromptTemplate max 20000 chars
  • admin.updatePrompt validates modelConfig.provider as "anthropic" or "openai"
  • admin.updatePrompt validates modelConfig.temperature range 0-2
  • admin.updatePrompt validates modelConfig.maxTokens range 100-8000
  • admin.getRevenue returns revenue data for requested period
  • admin.getSystemHealth checks DB, Redis, external APIs
  • admin.getSystemHealth returns "healthy" when all checks pass
  • admin.getSystemHealth returns "degraded" when any check fails

Agent Context (Pre-conditions)#

  • Required DB state: Practice (10+ records), Job (20+ records), ContentPiece (50+), SocialAccount (5+), PromptTemplate (5+), Domain (3+), User with ADMIN role
  • Required env vars: All service health check endpoints configured
  • Required external mocks: None for admin router (internal aggregation)

Verification Commands#

# Run unit tests for admin schemas
pnpm test:unit -- src/__tests__/unit/schemas/admin.test.ts

# Run integration tests for admin router
pnpm test:integration -- src/__tests__/integration/routers/admin.test.ts

# Run admin dashboard E2E
pnpm test:e2e -- e2e/admin/dashboard.spec.ts

15. Procedure & Auth Tests#

Spec Reference: docs/specs/backend-api.md Section 15 — Middleware & Procedures

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
publicProcedure.allowsUnauthenticated No session Call public endpoint Request allowed, handler executes
protectedProcedure.rejectsUnauthenticated No session Call protected endpoint Throws UNAUTHORIZED (401)
protectedProcedure.allowsAuthenticated Valid session Call protected endpoint Request allowed, ctx.session present
practiceProcedure.rejectsNoPractice Valid session, no practice context Call practice endpoint Throws BAD_REQUEST (400) — missing practice ID
practiceProcedure.rejectsNonMember Valid session, user not in practice Call practice endpoint Throws FORBIDDEN (403)
practiceProcedure.allowsViewer Valid session, user is VIEWER Call practice endpoint Request allowed
practiceProcedure.rejectsEditorForOwnerOps Valid session, user is EDITOR Call owner-only endpoint (e.g., delete practice) Throws FORBIDDEN (403)
adminProcedure.rejectsNonAdmin Valid session, role = USER Call admin endpoint Throws FORBIDDEN (403)
adminProcedure.allowsAdmin Valid session, role = ADMIN Call admin endpoint Request allowed
requestId.injection Any request Call any endpoint Response includes x-request-id header
requestId.propagation Request with ID Call endpoint that triggers external API External call includes X-RankFlow-Request-ID

Integration Tests#

Test Setup Action Assertion File
procedure.chain.public No auth Call health.check (publicProcedure) Returns 200 OK src/__tests__/integration/trpc/procedures.test.ts
procedure.chain.protected Valid session Call auth.me (protectedProcedure) Returns user data src/__tests__/integration/trpc/procedures.test.ts
procedure.chain.practice Valid session + practice member Call practice.get (practiceProcedure) Returns practice data src/__tests__/integration/trpc/procedures.test.ts
procedure.chain.admin Admin session Call admin.getKPIs (adminProcedure) Returns KPI data src/__tests__/integration/trpc/procedures.test.ts
procedure.chain.middlewareOrder Any request Call any endpoint Middleware executes in order: Rate Limit → Request ID → Auth → Practice Resolve → Role Check src/__tests__/integration/trpc/procedures.test.ts
context.dbInjection Any request Call any endpoint ctx.db is Prisma client with correct connection src/__tests__/integration/trpc/procedures.test.ts
context.redisInjection Any request Call any endpoint ctx.redis is Redis client with correct connection src/__tests__/integration/trpc/procedures.test.ts
context.sessionInjection Authenticated request Call protected endpoint ctx.session contains user ID, email, role src/__tests__/integration/trpc/procedures.test.ts
zodError.formatting Invalid input to endpoint Call with bad input Response includes zodError.fieldErrors with field names src/__tests__/integration/trpc/procedures.test.ts
zodError.flatten Invalid input to endpoint Call with bad input zodError.formErrors is empty array, fieldErrors populated src/__tests__/integration/trpc/procedures.test.ts

Success Criteria (Binary)#

  • publicProcedure allows requests without authentication
  • protectedProcedure rejects unauthenticated requests with 401
  • protectedProcedure allows authenticated requests with valid session
  • practiceProcedure rejects requests without practice context (400)
  • practiceProcedure rejects non-member users (403)
  • practiceProcedure allows any practice member (VIEWER, EDITOR, ADMIN, OWNER)
  • practiceProcedure owner-only operations reject non-OWNER members (403)
  • adminProcedure rejects non-admin users (403)
  • adminProcedure allows users with role = ADMIN
  • requestId is injected on every request as x-request-id header
  • requestId propagates to audit logs
  • requestId propagates to external API calls as X-RankFlow-Request-ID
  • ctx.db is available in all procedures and is a valid Prisma client
  • ctx.redis is available in all procedures and is a valid Redis client
  • ctx.session is populated for authenticated requests
  • ctx.session is null for unauthenticated requests on public endpoints
  • Zod errors are formatted with fieldErrors and formErrors structure
  • Zod errors are included in tRPC response data.zodError
  • Middleware executes in correct order: Rate Limit → Request ID → Auth → Practice Resolve → Role Check
  • Role check middleware reads role from PracticeMember table, not User.role
  • Practice resolution middleware extracts practiceId from input or headers

Agent Context (Pre-conditions)#

  • Required DB state: User (regular + admin), Practice with PracticeMember records for all roles
  • Required env vars: BETTER_AUTH_SECRET, REDIS_URL, DATABASE_URL
  • Required external mocks: None (internal middleware)

Verification Commands#

# Run procedure unit tests
pnpm test:unit -- src/__tests__/unit/trpc/procedures.test.ts

# Run procedure integration tests
pnpm test:integration -- src/__tests__/integration/trpc/procedures.test.ts

# Run all auth/procedure tests
pnpm test:unit -- src/__tests__/unit/trpc/

16. Rate Limiting Tests#

Spec Reference: docs/specs/backend-api.md Section 15 — Rate Limiting

Test Plan & Verification#

Unit Tests#

Test Input Expected Output File
rateLimit.config.skillExecute { route: "skill.execute", limit: 10, window: 60000 } Config matches spec src/__tests__/unit/rate-limit.test.ts
rateLimit.config.contentGenerate { route: "content.generate", limit: 5, window: 60000 } Config matches spec src/__tests__/unit/rate-limit.test.ts
rateLimit.config.gbpCreatePost { route: "gbp.createPost", limit: 30, window: 60000 } Config matches spec src/__tests__/unit/rate-limit.test.ts
rateLimit.config.socialCreatePost { route: "social.createPost", limit: 30, window: 60000 } Config matches spec src/__tests__/unit/rate-limit.test.ts
rateLimit.config.default { route: "any.other", limit: 100, window: 60000 } Config matches spec src/__tests__/unit/rate-limit.test.ts
rateLimit.keyGeneration Request with user ID usr_123 Key includes usr_123 and route name src/__tests__/unit/rate-limit.test.ts
rateLimit.keyAnonymous Request with no session Key includes IP address and route name src/__tests__/unit/rate-limit.test.ts

Integration Tests#

Test Setup Action Assertion File
rateLimit.skillExecute.10req Authenticated user Make 10 calls to skill.execute within 1 minute All 10 succeed src/__tests__/integration/rate-limit.test.ts
rateLimit.skillExecute.11req Authenticated user Make 11th call to skill.execute within 1 minute Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/rate-limit.test.ts
rateLimit.skillExecute.reset Authenticated user, rate limited Wait 1 minute, call skill.execute again Request succeeds after window reset src/__tests__/integration/rate-limit.test.ts
rateLimit.contentGenerate.5req Authenticated user Make 5 calls to content.generate within 1 minute All 5 succeed src/__tests__/integration/rate-limit.test.ts
rateLimit.contentGenerate.6req Authenticated user Make 6th call to content.generate within 1 minute Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/rate-limit.test.ts
rateLimit.gbpCreatePost.30req Authenticated user Make 30 calls to gbp.createPost within 1 minute All 30 succeed src/__tests__/integration/rate-limit.test.ts
rateLimit.gbpCreatePost.31req Authenticated user Make 31st call to gbp.createPost within 1 minute Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/rate-limit.test.ts
rateLimit.socialCreatePost.30req Authenticated user Make 30 calls to social.createPost within 1 minute All 30 succeed src/__tests__/integration/rate-limit.test.ts
rateLimit.socialCreatePost.31req Authenticated user Make 31st call to social.createPost within 1 minute Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/rate-limit.test.ts
rateLimit.default.100req Authenticated user Make 100 calls to practice.get within 1 minute All 100 succeed src/__tests__/integration/rate-limit.test.ts
rateLimit.default.101req Authenticated user Make 101st call to practice.get within 1 minute Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/rate-limit.test.ts
rateLimit.responseHeaders Rate limited request Call skill.execute over limit Response includes Retry-After header src/__tests__/integration/rate-limit.test.ts
rateLimit.anonymousRequest No session Make 100 calls from same IP All 100 succeed src/__tests__/integration/rate-limit.test.ts
rateLimit.anonymousExceeded No session Make 101st call from same IP Throws TOO_MANY_REQUESTS (429) src/__tests__/integration/rate-limit.test.ts
rateLimit.perUserIsolation User A at limit, User B fresh User B makes request to skill.execute User B succeeds despite User A being rate limited src/__tests__/integration/rate-limit.test.ts
rateLimit.perRouteIsolation skill.execute at limit Call content.generate (different route) Request succeeds (limits are per-route) src/__tests__/integration/rate-limit.test.ts

Success Criteria (Binary)#

  • skill.execute rate limit: 10 requests per minute per user
  • content.generate rate limit: 5 requests per minute per user
  • gbp.createPost rate limit: 30 requests per minute per user
  • social.createPost rate limit: 30 requests per minute per user
  • All other routes rate limit: 100 requests per minute per user
  • Rate limit uses Redis for distributed counting
  • Rate limit key includes user ID (or IP for anonymous) and route name
  • Rate limit windows are sliding (60 seconds from first request in window)
  • Rate limit returns 429 with Retry-After header when exceeded
  • Rate limit counters reset after window expires
  • Rate limits are isolated per user (User A limit does not affect User B)
  • Rate limits are isolated per route (route A limit does not affect route B)
  • Anonymous requests use IP address as rate limit identifier
  • Rate limit middleware runs before auth middleware in the chain
  • Rate limit response includes tRPC error code TOO_MANY_REQUESTS

Agent Context (Pre-conditions)#

  • Required DB state: User (2+ users for isolation tests)
  • Required env vars: REDIS_URL, RATE_LIMIT_ENABLED=true
  • Required external mocks: Redis instance (test container or mock)

Verification Commands#

# Run rate limiting unit tests
pnpm test:unit -- src/__tests__/unit/rate-limit.test.ts

# Run rate limiting integration tests
pnpm test:integration -- src/__tests__/integration/rate-limit.test.ts

# Run rate limit stress test
pnpm test:integration -- src/__tests__/integration/rate-limit.test.ts --grep "stress"

17. Global Verification Commands#

Run All Backend API Tests#

# 1. Run all unit tests (schemas, utilities, procedures)
pnpm test:unit

# 2. Run all integration tests (routers, DB, middleware)
pnpm test:integration

# 3. Run all contract tests (external API mocks)
pnpm test:contract

# 4. Run all E2E tests (critical flows)
pnpm test:e2e

# 5. Run router-specific integration tests
pnpm test:integration -- src/__tests__/integration/routers/auth.test.ts
pnpm test:integration -- src/__tests__/integration/routers/practice.test.ts
pnpm test:integration -- src/__tests__/integration/routers/location.test.ts
pnpm test:integration -- src/__tests__/integration/routers/gbp.test.ts
pnpm test:integration -- src/__tests__/integration/routers/social.test.ts
pnpm test:integration -- src/__tests__/integration/routers/content.test.ts
pnpm test:integration -- src/__tests__/integration/routers/citation.test.ts
pnpm test:integration -- src/__tests__/integration/routers/directory-profile.test.ts
pnpm test:integration -- src/__tests__/integration/routers/report.test.ts
pnpm test:integration -- src/__tests__/integration/routers/lead.test.ts
pnpm test:integration -- src/__tests__/integration/routers/billing.test.ts
pnpm test:integration -- src/__tests__/integration/routers/skill.test.ts
pnpm test:integration -- src/__tests__/integration/routers/admin.test.ts

# 6. Run all schema unit tests
pnpm test:unit -- src/__tests__/unit/schemas/

# 7. Run procedure and middleware tests
pnpm test:unit -- src/__tests__/unit/trpc/
pnpm test:integration -- src/__tests__/integration/trpc/

# 8. Run rate limiting tests
pnpm test:integration -- src/__tests__/integration/rate-limit.test.ts

# 9. Full backend API test suite
pnpm test -- src/__tests__/unit/schemas/ src/__tests__/unit/trpc/ src/__tests__/integration/routers/ src/__tests__/integration/trpc/ src/__tests__/integration/rate-limit.test.ts

Coverage Requirements#

Category Minimum Coverage Target Coverage
Zod Schemas 95% 100%
tRPC Procedures 90% 95%
Router Integration 85% 90%
Auth Middleware 95% 100%
Rate Limiting 90% 95%
Error Handling 85% 90%

End of Backend API Test Specification — RankFlow AI v1.0.0