Browse documentation

Test Specs

RankFlow AI — Test Specification: Reporting, Compliance & Security

Flow: Report Data → HTML Template (React) → Playwright (Chromium) → PDF Buffer → S3 Upload → Resend Attachment

docs/test-specs/TEST-reporting-compliance.md
On this page

Version: 1.0.0 Date: 2026-06-12 Scope: Monthly/Weekly Reports, Consent, Medical Compliance, Schema Validity, Security Source Specs: email-reports-pdf.md, security-compliance.md, business_flow_map.md §5.7 & §3 (Consent) Test Framework: Vitest + Playwright + MSW + Prisma Test Utils Compliance Framework: DPDPA 2023, GDPR-ready, Medical Content Safety


1. Monthly PDF Report Generation#

Flow: Report Data → HTML Template (React) → Playwright (Chromium) → PDF Buffer → S3 Upload → Resend Attachment

Unit Tests#

Test Input Expected Output File
buildReportPdfHtml MockReportData Valid HTML string with all 9 sections src/__tests__/unit/pdf/template.test.ts
buildReportPdfHtml missing fields Partial<ReportData> No undefined or NaN rendered in HTML src/__tests__/unit/pdf/template.test.ts
generatePdf mock HTML string + filename Buffer length > 0, S3 Key = reports/{filename} src/__tests__/unit/pdf/playwright.test.ts
PDF header/footer Any HTML Header contains "RankFlow AI", footer contains page numbers src/__tests__/unit/pdf/playwright.test.ts
S3 upload URL report-prac-123-January-2025.pdf URL matches https://{bucket}.s3.{region}.amazonaws.com/reports/... src/__tests__/unit/pdf/s3.test.ts
Font loading wait HTML with Google Fonts page.waitForTimeout(2000) called before page.pdf() src/__tests__/unit/pdf/playwright.test.ts
Browser close guarantee Any input browser.close() called in finally block even on error src/__tests__/unit/pdf/playwright.test.ts

Integration Tests#

Test Setup Action Assertion File
Full PDF pipeline MockReportData, MSW S3 stub, MSW Resend stub Call generatePdf(buildReportPdfHtml(data), filename) PDF buffer > 0 bytes, S3 PutObject called, URL returned src/__tests__/integration/pdf/report-pipeline.test.ts
Report DB record creation Prisma test DB with Practice + Location Inngest step runs db.report.create Report row exists with status = "READY", pdfUrl populated, scoreOverall is number src/__tests__/integration/pdf/report-db.test.ts
Monthly report cron Mock Inngest cron trigger 0 4 1 * * Invoke monthlyReport function Iterates all ACTIVE practices, generates one report per practice src/__tests__/integration/pdf/monthly-cron.test.ts
PDF generation failure Playwright throws browser.launch error Call generatePdf Error thrown, browser closed, no S3 call made src/__tests__/integration/pdf/failure-handling.test.ts
S3 upload failure S3 returns 403 Call generatePdf Error thrown after PDF generation, browser still closed src/__tests__/integration/pdf/failure-handling.test.ts

E2E Tests#

Flow Steps Expected End State File
Monthly report end-to-end 1. Seed DB with 2 active practices. 2. Trigger monthly report Inngest function. 3. Wait for all steps. 2 Report records in DB, 2 PDFs in S3 (mocked), 2 EmailLog records with type = MONTHLY_REPORT e2e/reporting/monthly-report.spec.ts
Report dashboard view 1. Login as CLIENT. 2. Navigate to /dashboard/reports. Page renders report list, latest report shows score, PDF download link works e2e/reporting/dashboard-reports.spec.ts

Success Criteria (Binary)#

  • PDF renders A4 format with print backgrounds enabled
  • All 9 report sections appear in generated PDF HTML template
  • S3 upload succeeds with ContentType: application/pdf
  • Browser process is always closed (no Chromium leaks)
  • Report DB record created with correct period, scores, and pdfUrl
  • Font wait timeout (2000ms) prevents layout shift in PDF output
  • Monthly cron runs only for practices with status = ACTIVE
  • Failed PDF generation does not create orphaned Report records

Agent Context (Pre-conditions)#

  • Required DB state: Practice (≥1 active), Location linked, Report table empty
  • Required env vars: AWS_S3_BUCKET, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, ENCRYPTION_KEY
  • Required external mocks: S3 PutObject → 200 OK, Playwright Chromium available in test env
  • Required packages: playwright, @aws-sdk/client-s3, vitest, msw

Verification Commands#

# Unit tests
pnpm test:unit -- src/__tests__/unit/pdf/

# Integration tests
pnpm test:integration -- src/__tests__/integration/pdf/

# E2E test
pnpm test:e2e -- e2e/reporting/monthly-report.spec.ts

2. Score Calculation#

Formula: Weighted composite score (0–100) across 5 categories.

Unit Tests#

Test Input Expected Output File
calculateOverallScore all perfect scoreGbp=100, scoreCitations=100, scoreReviews=100, scoreRankings=100, scoreSeo=100 100 src/__tests__/unit/reporting/score.test.ts
calculateOverallScore all zero All category scores = 0 0 src/__tests__/unit/reporting/score.test.ts
calculateGbpScore baseline views=500, clicks=100, calls=20, directions=10, baseline = 1000 (500+100+20+10)/1000*100 = 63 src/__tests__/unit/reporting/score.test.ts
calculateCitationsScore live=24, verified=30, napConsistency=0.95 (24/30*100)*0.95 = 76 src/__tests__/unit/reporting/score.test.ts
calculateReviewsScore avgRating=4.2, replyRate=0.8 (4.2/5*50)+(0.8*50)=42+40=82 src/__tests__/unit/reporting/score.test.ts
calculateRankingsScore top10=12, total=30 12/30*100 = 40 src/__tests__/unit/reporting/score.test.ts
calculateSeoScore health=90, schemaValid=1, speed=85 (90+100+85)/3 ≈ 91.67 (implementation-defined) src/__tests__/unit/reporting/score.test.ts
Score capped at 100 Any category input yielding >100 Math.min(100, rawScore) src/__tests__/unit/reporting/score.test.ts
Score floored at 0 Any negative input Math.max(0, rawScore) src/__tests__/unit/reporting/score.test.ts
Weighted sum exact 25,25,20,20,10 0.25*a + 0.25*b + 0.20*c + 0.20*d + 0.10*e src/__tests__/unit/reporting/score.test.ts

Integration Tests#

Test Setup Action Assertion File
Score on data update Update GbpLocation views + clicks Trigger score recalculation scoreGbp updated in Report or Practice table src/__tests__/integration/reporting/score-update.test.ts
Score consistency Report record exists Read scoreOverall Equals weighted sum of stored category scores src/__tests__/integration/reporting/score-consistency.test.ts
Zero baseline handling baseline = 0 Call calculateGbpScore Returns 0 (no division by zero) src/__tests__/integration/reporting/score-edge.test.ts
Missing data fallback rankings array empty Call calculateRankingsScore Returns 0 instead of NaN src/__tests__/integration/reporting/score-edge.test.ts

Success Criteria (Binary)#

  • Overall score formula exactly matches: 0.25*GBP + 0.25*Citations + 0.20*Reviews + 0.20*Rankings + 0.10*SEO
  • Each category score is clamped to [0, 100] before weighting
  • Division by zero in GBP baseline returns 0, not Infinity or NaN
  • Empty rankings array returns 0, not NaN
  • Score recalculation updates DB within 5 seconds of data change event
  • Report scores are persisted as integers or 2-decimal floats consistently

Agent Context (Pre-conditions)#

  • Required DB state: Practice with linked GbpLocation, Citation, Review, KeywordRanking records
  • Required env vars: None (pure math)
  • Required external mocks: None

Verification Commands#

pnpm test:unit -- src/__tests__/unit/reporting/score.test.ts
pnpm test:integration -- src/__tests__/integration/reporting/

3. Email Report Delivery#

System: Resend API → HTML email + PDF attachment → Webhook tracking (email.delivered, email.opened, email.bounced)

Unit Tests#

Test Input Expected Output File
buildMonthlyReportEmail MockReportData HTML contains scoreOverall, 4 metric cards, 3 action items, CTA link src/__tests__/unit/email/template.test.ts
buildMonthlyReportEmail responsive Any data CSS @media query present for max-width: 600px src/__tests__/unit/email/template.test.ts
buildAlertEmail high priority priority: "high" Background color #dc2626, text contains "HIGH" src/__tests__/unit/email/template.test.ts
buildAlertEmail medium priority priority: "medium" Background color #f59e0b src/__tests__/unit/email/template.test.ts
buildAlertEmail low priority priority: "low" Background color #2563eb src/__tests__/unit/email/template.test.ts
Email payload construction to, subject, html, attachments Payload matches Resend API schema src/__tests__/unit/email/payload.test.ts
Webhook signature verification Valid body + signature verifyWebhookSignature returns true src/__tests__/unit/email/webhook.test.ts
Webhook signature verification Invalid signature verifyWebhookSignature returns false src/__tests__/unit/email/webhook.test.ts
Missing email_id in webhook Event without email_id Handler returns 400 (no DB crash) src/__tests__/unit/email/webhook.test.ts

Integration Tests#

Test Setup Action Assertion File
Resend send monthly report MSW Resend stub → { id: "msg_123" } Call email.send({ to, html, attachments: [pdfUrl] }) Resend API called with attachments array, EmailLog created with status = SENT src/__tests__/integration/email/resend-send.test.ts
Resend delivery webhook EmailLog with id = "msg_123", status = SENT POST webhook email.delivered EmailLog.status updated to DELIVERED, deliveredAt set src/__tests__/integration/email/webhook-delivery.test.ts
Resend open webhook EmailLog with status = DELIVERED POST webhook email.opened EmailLog.status updated to OPENED, openedAt set src/__tests__/integration/email/webhook-open.test.ts
Resend bounce webhook EmailLog with status = SENT POST webhook email.bounced with reason = "bounce" EmailLog.status updated to BOUNCED, bouncedAt set, bounceReason recorded src/__tests__/integration/email/webhook-bounce.test.ts
Invalid webhook signature Wrong signature header POST webhook Response status 401, no DB update src/__tests__/integration/email/webhook-security.test.ts
PDF attachment URL validity pdfUrl from S3 Send email attachments contains absolute HTTPS URL src/__tests__/integration/email/attachment.test.ts
Email batch send 10 practices Trigger monthly report cron 10 separate email.send calls, 10 EmailLog records src/__tests__/integration/email/batch-send.test.ts

E2E Tests#

Flow Steps Expected End State File
Client receives monthly report 1. Admin triggers report. 2. Mock Resend delivers. 3. Webhook updates status. EmailLog.status = DELIVERED within 10 seconds e2e/reporting/email-delivery.spec.ts
Client opens report 1. Resend webhook sends email.opened. EmailLog.status = OPENED e2e/reporting/email-delivery.spec.ts

Success Criteria (Binary)#

  • HTML email contains correct practiceName, period, scoreOverall, and 4 score breakdown cards
  • PDF attachment URL is included in email payload sent to Resend
  • Resend webhook signature is verified with HMAC before processing
  • email.delivered updates EmailLog.status to DELIVERED and sets deliveredAt
  • email.opened updates EmailLog.status to OPENED and sets openedAt
  • email.bounced updates EmailLog.status to BOUNCED, sets bouncedAt and bounceReason
  • Invalid webhook signature returns 401 without DB mutation
  • Batch monthly report sends one email per active practice, no duplicates
  • Responsive CSS @media query present for mobile clients
  • Bounce reason is stored in EmailLog for admin review

Agent Context (Pre-conditions)#

  • Required DB state: Practice (active), User (owner), EmailLog table empty
  • Required env vars: RESEND_API_KEY, RESEND_WEBHOOK_SECRET
  • Required external mocks: Resend API → { id: "msg_123" }, Resend webhook events

Verification Commands#

pnpm test:unit -- src/__tests__/unit/email/
pnpm test:integration -- src/__tests__/integration/email/
pnpm test:e2e -- e2e/reporting/email-delivery.spec.ts

4. Weekly Digest Generation#

Flow: BullMQ cron (Mondays) → Gather top 3 stats → HTML email → Resend → No PDF attachment

Unit Tests#

Test Input Expected Output File
buildWeeklyDigestHtml MockDigestData HTML contains Maps position, new reviews count, posts published count src/__tests__/unit/email/digest.test.ts
buildWeeklyDigestHtml positive change mapsPosition=3, previousPosition=4 Shows "+1 from last wk" with up arrow src/__tests__/unit/email/digest.test.ts
buildWeeklyDigestHtml negative change mapsPosition=5, previousPosition=3 Shows down arrow and position drop src/__tests__/unit/email/digest.test.ts
No data week All metrics zero HTML renders "0" or "No new activity" (no undefined) src/__tests__/unit/email/digest.test.ts

Integration Tests#

Test Setup Action Assertion File
Weekly digest cron Mock BullMQ cron trigger for Monday Invoke digest worker EmailLog created with type = WEEKLY_DIGEST, no attachments src/__tests__/integration/email/weekly-digest.test.ts
Digest stats aggregation Review (4 new), GbpPost (2 published), KeywordRanking (position #3) Call gatherDigestData Returns object with newReviews=4, postsPublished=2, mapsPosition=3 src/__tests__/integration/email/weekly-digest.test.ts
Digest delivery tracking Resend stub → 200 OK Send digest EmailLog.status = SENT src/__tests__/integration/email/weekly-digest.test.ts

Success Criteria (Binary)#

  • Weekly digest contains exactly 3 stats: Maps position, new reviews, posts published
  • Digest email has no PDF attachment (lightweight)
  • Position change relative to previous week is calculated and displayed
  • EmailLog.type = WEEKLY_DIGEST is recorded for tracking
  • Digest renders gracefully when all metrics are zero
  • Cron runs every Monday morning (configurable via BullMQ or Inngest)

Agent Context (Pre-conditions)#

  • Required DB state: Practice (active), GbpPost, Review, KeywordRanking data for last 7 days
  • Required env vars: RESEND_API_KEY
  • Required external mocks: Resend API → 200 OK

Verification Commands#

pnpm test:unit -- src/__tests__/unit/email/digest.test.ts
pnpm test:integration -- src/__tests__/integration/email/weekly-digest.test.ts

Legal Basis: DPDPA 2023 (India), GDPR-ready. Consent recorded with timestamp + IP + user agent.

Unit Tests#

Test Input Expected Output File
recordConsent all required userId, practiceId, consentType="terms", ip="1.2.3.4", ua="Mozilla/5.0" ConsentLog row with granted=true, grantedAt is Date, ipAddress set src/__tests__/unit/compliance/consent.test.ts
recordConsent marketing opt-out consentType="marketing", granted=false ConsentLog row with granted=false, grantedAt set src/__tests__/unit/compliance/consent.test.ts
withdrawConsent userId, practiceId New ConsentLog with granted=false, withdrawnAt set; Inngest event sent src/__tests__/unit/compliance/consent.test.ts
Duplicate consent idempotent Same params called twice Two ConsentLog rows (audit trail requires immutability) src/__tests__/unit/compliance/consent.test.ts
IP address format validation ip="999.999.999.999" zod or regex validation rejects invalid IP src/__tests__/unit/compliance/consent.test.ts
Missing required consent blocks terms=false, privacy=true isOnboardingAllowed returns false src/__tests__/unit/compliance/consent.test.ts
All required consents present terms=true, privacy=true, gbp_auth=true, data_processing=true, citation_network=true isOnboardingAllowed returns true src/__tests__/unit/compliance/consent.test.ts
Consent log timestamp precision Any input grantedAt stored with millisecond precision (not truncated to date) src/__tests__/unit/compliance/consent.test.ts

Integration Tests#

Test Setup Action Assertion File
Onboarding consent flow Prisma test DB with User + Practice Call onboarding consent step 5 ConsentLog rows created (terms, privacy, gbp_auth, data_processing, citation_network) src/__tests__/integration/compliance/onboarding-consent.test.ts
Consent withdrawal triggers deletion ConsentLog exists, Practice active Call withdrawConsent Inngest event compliance/data-deletion dispatched with correct practice_id src/__tests__/integration/compliance/withdrawal.test.ts
Consent log write failure retry Prisma throws P2018 (connection) Call recordConsent Retries 3× with exponential backoff, then alerts admin (mocked logger) src/__tests__/integration/compliance/consent-retry.test.ts
Marketing consent unchecked default New signup Check ConsentLog for marketing granted=false or record absent (default opt-out) src/__tests__/integration/compliance/marketing-default.test.ts
GDPR export includes consent Practice with 3 consent logs Call exportPracticeData Export JSON contains consentLogs array with all records src/__tests__/integration/compliance/gdpr-export.test.ts
Admin audit log on consent Any consent action Trigger consent recording AuditLog row created with action = CONSENT, entityType = ConsentLog src/__tests__/integration/compliance/audit-consent.test.ts

Compliance Tests#

Test Trigger Test Failure Action
Required consent completeness Onboarding progression Query ConsentLog for required types; all must exist Block progression to next step
Timestamp integrity Consent record grantedAt must be within 5 seconds of now() Flag for admin review if stale
IP address presence Consent record ipAddress is non-empty string Block if IP cannot be determined (require proxy header)
Withdrawal immutability Withdrawal request New ConsentLog row created; previous rows unchanged Ensure audit trail is append-only
DPDPA purpose limitation Data export Verify data usage matches declared purpose (SEO services only) Alert compliance officer
Storage limitation Cancelled practice Auto-delete ConsentLog after 1 year post-cancellation Verify cron job removes old records

Success Criteria (Binary)#

  • Every required consent (terms, privacy, gbp_auth, data_processing, citation_network) creates a ConsentLog row with granted=true
  • Each ConsentLog record contains ipAddress, userAgent, and grantedAt timestamp
  • Marketing consent defaults to false (unchecked by default)
  • Withdrawal creates a new ConsentLog with granted=false and triggers compliance/data-deletion Inngest event
  • Onboarding cannot proceed if any required consent is missing
  • Consent DB write failure retries 3× before alerting admin
  • GDPR data export includes all ConsentLog records for the practice
  • AuditLog records every consent action with action = CONSENT
  • Consent records are append-only (no updates to existing rows)
  • Invalid IP addresses are rejected by input validation

Agent Context (Pre-conditions)#

  • Required DB state: User, Practice, ConsentLog, AuditLog tables empty
  • Required env vars: INNGEST_EVENT_KEY (for withdrawal event)
  • Required external mocks: Inngest send stubbed to capture events

Verification Commands#

pnpm test:unit -- src/__tests__/unit/compliance/consent.test.ts
pnpm test:integration -- src/__tests__/integration/compliance/
pnpm test:compliance -- src/__tests__/compliance/consent/

6. Medical Content Compliance#

Rules: No guaranteed cures, no drug claims, no patient PII in replies, HIPAA-aware handling.

Unit Tests#

Test Input Expected Output File
medicalComplianceFilter banned phrase "guaranteed cure" Content containing "guaranteed cure for cancer" isCompliant = false, violations = ["guaranteed cure"] src/__tests__/unit/compliance/medical-filter.test.ts
medicalComplianceFilter banned phrase "100% success" Content containing "100% success rate" isCompliant = false src/__tests__/unit/compliance/medical-filter.test.ts
medicalComplianceFilter drug name claim Content containing "This drug will cure your migraine" isCompliant = false, violations includes drug claim src/__tests__/unit/compliance/medical-filter.test.ts
medicalComplianceFilter safe content "We offer dental checkups and cleaning services." isCompliant = true, violations = [] src/__tests__/unit/compliance/medical-filter.test.ts
stripPatientNames review reply "Thank you, Dr. Rajesh, for your visit." Returns text without patient names (if name detected) src/__tests__/unit/compliance/pii-filter.test.ts
stripPatientNames no PII "Thank you for your visit." Unchanged text src/__tests__/unit/compliance/pii-filter.test.ts
containsDrugNames paracetamol Content mentions "paracetamol, ibuprofen, aspirin" drugsFound = ["paracetamol", "ibuprofen", "aspirin"] src/__tests__/unit/compliance/medical-filter.test.ts
containsDrugNames no drugs "Dental implants and braces consultation." drugsFound = [] src/__tests__/unit/compliance/medical-filter.test.ts
Content regeneration on failure Non-compliant content generated regenerateWithCompliancePrompt called with stricter system prompt src/__tests__/unit/compliance/regeneration.test.ts
Max regeneration attempts 3 failures in a row Alert admin via logger/Slack; content queued for manual review src/__tests__/unit/compliance/regeneration.test.ts

Integration Tests#

Test Setup Action Assertion File
AI content generation compliance gate Mock LLM returns non-compliant text Call ai.generate({ task: "profile_content_ready" }) Content rejected before DB write, regeneration triggered src/__tests__/integration/compliance/ai-generation-gate.test.ts
GBP post compliance gate Mock LLM returns "guaranteed cure" Call createGbpPost Post stored with status = PENDING_REVIEW, not PUBLISHED src/__tests__/integration/compliance/gbp-post-gate.test.ts
Review reply PII strip Review with reviewerName = "Patient_Anon_123" Call generateReviewReply Reply does not contain patient name or any identifiable PII src/__tests__/integration/compliance/review-reply-pii.test.ts
Citation description compliance 30 citation descriptions generated Run compliance filter on all All descriptions pass; any failure triggers regeneration src/__tests__/integration/compliance/citation-descriptions.test.ts
Directory profile content compliance DirectoryProfileSection content from LLM Run compliance filter All sections pass before profilePublished = true src/__tests__/integration/compliance/directory-profile-gate.test.ts
Admin alert on 3× failure Mock LLM always returns banned phrase Trigger content generation Admin alert logged after 3rd regeneration attempt src/__tests__/integration/compliance/admin-alert.test.ts

Compliance Tests#

Test Trigger Test Failure Action
No guaranteed cures AI content generation Regex scan for guaranteed cure, 100% success, permanent solution, miracle treatment Reject + regenerate + alert if 3×
No drug claims AI content generation Entity recognition for drug names (paracetamol, ibuprofen, aspirin, metformin, etc.) + verb claim context (cure, treat, heal) Flag for review if drug found
HIPAA/PHI safe Review reply generation Detect patient names, phone numbers, dates of birth in reply text Strip + regenerate
No medical advice AI content generation Check for diagnostic language (you have, you should, prescribe, diagnosis) Reject + regenerate with disclaimer prompt
Disclaimers present Approved content Verify every medical page contains This information is not a substitute for professional medical advice. Block publish if missing
Content quality heuristic AI output Length check (min 100 chars), keyword density < 5% Flag for admin review if low quality

Success Criteria (Binary)#

  • Banned phrases (guaranteed cure, 100% success, permanent solution, miracle treatment) are detected and rejected
  • Drug names found in content trigger isCompliant = false when paired with claim verbs
  • Review replies never contain patient names, phone numbers, or dates of birth
  • Non-compliant AI content is blocked from DB write and triggers regeneration
  • After 3 regeneration failures, admin alert is sent and content queues for manual review
  • All medical directory profiles contain required disclaimer before publish
  • Citation descriptions (30 per practice) all pass compliance filter before submission
  • GBP posts with compliance violations are queued as PENDING_REVIEW, not auto-published
  • Content quality heuristic enforces minimum length and keyword density limits
  • Diagnostic language (you have, prescribe, diagnosis) is rejected in AI-generated content

Agent Context (Pre-conditions)#

  • Required DB state: Practice, ContentPiece, DirectoryProfileSection, Review tables
  • Required env vars: ANTHROPIC_API_KEY (or mock LLM router)
  • Required external mocks: LLM router returning controlled responses (compliant + non-compliant)
  • Required data: Banned phrase list, drug name dictionary, disclaimer template

Verification Commands#

pnpm test:unit -- src/__tests__/unit/compliance/medical-filter.test.ts
pnpm test:integration -- src/__tests__/integration/compliance/
pnpm test:compliance -- src/__tests__/compliance/medical/

7. Schema Markup Validity#

Standard: JSON-LD, Schema.org (LocalBusiness, MedicalBusiness, Physician, Service, FAQPage, Review). Validated on every deploy.

Unit Tests#

Test Input Expected Output File
buildLocalBusinessSchema MockPractice + Location Valid JSON-LD string with @type: "LocalBusiness", @context: "https://schema.org" src/__tests__/unit/schema/builder.test.ts
buildMedicalBusinessSchema Dentist practice @type: "MedicalBusiness" or "Physician" with medicalSpecialty src/__tests__/unit/schema/builder.test.ts
buildFaqSchema 3 Q&A pairs FAQPage schema with mainEntity array of Question / Answer src/__tests__/unit/schema/builder.test.ts
buildServiceSchema 2 services Service schema array with provider, areaServed, serviceType src/__tests__/unit/schema/builder.test.ts
buildReviewSchema 2 reviews AggregateRating + Review array with reviewRating, author src/__tests__/unit/schema/builder.test.ts
validateJsonLd valid JSON Well-formed JSON-LD valid = true, errors = [] src/__tests__/unit/schema/validator.test.ts
validateJsonLd invalid JSON Malformed JSON string valid = false, errors contains parse error src/__tests__/unit/schema/validator.test.ts
validateJsonLd missing @context JSON without @context valid = false, errors contains "Missing @context" src/__tests__/unit/schema/validator.test.ts
validateJsonLd missing @type JSON without @type valid = false, errors contains "Missing @type" src/__tests__/unit/schema/validator.test.ts
Schema inject into HTML schema object + HTML template <script type="application/ld+json"> present in <head> src/__tests__/unit/schema/injector.test.ts
Schema inject escaping Content with </script> or quotes Malicious closing tags escaped, JSON safe src/__tests__/unit/schema/injector.test.ts
Combined schema object Multiple schema types @graph array or nested hasOfferCatalog structure present src/__tests__/unit/schema/builder.test.ts

Integration Tests#

Test Setup Action Assertion File
Directory profile schema presence Practice with schema stored Render directory profile HTML <script type="application/ld+json"> found in HTML <head> src/__tests__/integration/schema/directory-profile.test.ts
ISR revalidation schema Update Practice schema field Trigger directoryProfile.publish New HTML contains updated schema, old CDN cache invalidated src/__tests__/integration/schema/isr-revalidation.test.ts
Schema DB storage Practice onboarding complete Call schema builder + save Practice.schemaMarkup stores valid JSON object src/__tests__/integration/schema/db-storage.test.ts
Invalid schema fallback Malformed JSON from LLM Call validateJsonLd + deploySite Fallback to minimal LocalBusiness schema, page still deploys, admin flagged src/__tests__/integration/schema/invalid-fallback.test.ts
Google Rich Results validation Valid schema generated Call external validator (mocked) Returns richResultsEligible = true for required fields src/__tests__/integration/schema/rich-results.test.ts
Multi-location schema Practice with 2 Location records Build schema department or areaServed covers both locations src/__tests__/integration/schema/multi-location.test.ts
FAQ schema minimum count DirectoryProfileSection FAQ with 8 Q&A Build schema FAQPage schema valid if ≥ 8 Q&A pairs; warning if < 8 src/__tests__/integration/schema/faq-count.test.ts

Compliance Tests#

Test Trigger Test Failure Action
JSON-LD parseability Directory profile deploy JSON.parse() on schema string Fallback to minimal schema if parse fails
Required fields present Schema validation name, address, telephone, @context, @type must exist Block deploy if missing
Schema.org vocabulary Schema validation @type values must be from Schema.org core or health extension Flag for admin review if unknown
No duplicate schema HTML validation Only one <script type="application/ld+json"> per type in <head> Remove duplicates before deploy
NAP consistency in schema Schema vs Location table telephone, address in schema must match Location record Alert if mismatch detected
Medical schema enrichment category = DENTIST MedicalBusiness or Physician with medicalSpecialty: "Dentistry" Flag if generic LocalBusiness used for medical vertical

Success Criteria (Binary)#

  • Every directory profile <head> contains at least one valid JSON-LD <script> block
  • Schema @context is exactly https://schema.org
  • @type is valid Schema.org type (LocalBusiness, MedicalBusiness, Physician, Service, FAQPage, Review)
  • Required fields name, address, telephone are present and non-empty
  • Invalid JSON-LD triggers fallback to minimal LocalBusiness schema without breaking deploy
  • FAQ schema contains ≥ 8 Q&A pairs for medical vertical pages
  • Schema telephone and address match Location table NAP exactly
  • Medical practices use MedicalBusiness or Physician type, not generic LocalBusiness
  • Schema injection escapes </script> and quote characters to prevent XSS
  • ISR revalidation updates schema in HTML within 60 seconds of Practice update
  • No duplicate schema blocks of the same type in page <head>
  • AggregateRating schema reflects actual Review data average and count

Agent Context (Pre-conditions)#

  • Required DB state: Practice with schemaMarkup JSON field, Location, DirectoryProfileSection, Review
  • Required env vars: None
  • Required external mocks: Google Rich Results Test API (optional, mocked)

Verification Commands#

pnpm test:unit -- src/__tests__/unit/schema/
pnpm test:integration -- src/__tests__/integration/schema/
pnpm test:compliance -- src/__tests__/compliance/schema/

8. Security#

Layers: Token Encryption (AES-256-GCM), RBAC, Rate Limiting, Input Validation, SQL Injection Prevention, Audit Logging.

Unit Tests#

Test Input Expected Output File
encrypt round-trip plaintext = "secret-token-12345" decrypt(encrypt(plaintext)) === plaintext src/__tests__/unit/security/crypto.test.ts
encrypt output format Any string iv:authTag:ciphertext format, 3 colon-separated base64 parts src/__tests__/unit/security/crypto.test.ts
encrypt unique IV Same plaintext encrypted twice Two ciphertexts are different (IV randomness) src/__tests__/unit/security/crypto.test.ts
decrypt tampered authTag Flip one bit in authTag decrypt throws Unsupported state or unable to authenticate data src/__tests__/unit/security/crypto.test.ts
decrypt tampered ciphertext Flip one bit in ciphertext decrypt throws authentication error src/__tests__/unit/security/crypto.test.ts
encrypt with wrong key ENCRYPTION_KEY env missing scryptSync throws or getKey errors src/__tests__/unit/security/crypto.test.ts
publicProcedure allows all No auth token Request succeeds src/__tests__/unit/security/rbac.test.ts
protectedProcedure blocks guest No auth token tRPC returns UNAUTHORIZED src/__tests__/unit/security/rbac.test.ts
practiceProcedure blocks non-member Auth token for user not in practice tRPC returns FORBIDDEN src/__tests__/unit/security/rbac.test.ts
adminProcedure blocks client Auth token with role = CLIENT tRPC returns FORBIDDEN src/__tests__/unit/security/rbac.test.ts
adminProcedure allows admin Auth token with role = ADMIN Request succeeds src/__tests__/unit/security/rbac.test.ts
editorProcedure allows edit role = EDITOR content.update succeeds src/__tests__/unit/security/rbac.test.ts
viewerProcedure blocks mutation role = VIEWER content.update returns FORBIDDEN src/__tests__/unit/security/rbac.test.ts
Rate limit within window 99 requests in 1 min All succeed src/__tests__/unit/security/rate-limit.test.ts
Rate limit exceeded 101 requests in 1 min 101st returns 429 Too Many Requests src/__tests__/unit/security/rate-limit.test.ts
skill.execute rate limit 11 requests in 1 min 11th returns 429 src/__tests__/unit/security/rate-limit.test.ts
content.generate rate limit 6 requests in 1 min 6th returns 429 src/__tests__/unit/security/rate-limit.test.ts
Input validation Zod name = "A" (too short) z.string().min(2) rejects with ZodError src/__tests__/unit/security/validation.test.ts
Input sanitization rawContent = "<script>alert(1)</script>" DOMPurify.sanitize removes <script> tag src/__tests__/unit/security/validation.test.ts
SQL injection in slug slug = "test'; DROP TABLE users; --" Prisma query parameterization prevents injection; no table dropped src/__tests__/unit/security/sql-injection.test.ts
SQL injection in search q = "1 OR 1=1" db.$queryRaw with tagged template or Prisma findMany safe src/__tests__/unit/security/sql-injection.test.ts
Audit log creation logAudit called AuditLog row created with action, entityType, oldValue, newValue src/__tests__/unit/security/audit.test.ts
Request ID propagation Incoming request Response header x-request-id present and matches audit log src/__tests__/unit/security/audit.test.ts
Session expiry Token created 8 days ago betterAuth session validation rejects as expired src/__tests__/unit/security/session.test.ts
Session refresh Token 2 days old updateAge triggers refresh if > 24h src/__tests__/unit/security/session.test.ts

Integration Tests#

Test Setup Action Assertion File
Token encryption at rest Create GbpAccount with accessToken = "test" Read DB row accessToken is encrypted ciphertext (not plaintext) src/__tests__/integration/security/token-encryption.test.ts
Token decryption for API call GbpAccount with encrypted token Call GBP API method Token decrypted successfully, API call authenticated src/__tests__/integration/security/token-decrypt-api.test.ts
Role escalation attempt role = CLIENT Call adminProcedure mutation Returns 403 FORBIDDEN, no DB mutation src/__tests__/integration/security/rbac-escalation.test.ts
Practice isolation User in Practice A Query Practice B data Returns 404 or 403 (no data leakage) src/__tests__/integration/security/practice-isolation.test.ts
Admin impersonation audit ADMIN impersonates Practice A Perform action AuditLog records action = IMPERSONATE, real admin userId src/__tests__/integration/security/impersonation.test.ts
Rate limit headers 100 requests consumed Make request Response header x-ratelimit-remaining = 0 src/__tests__/integration/security/rate-limit-headers.test.ts
Webhook replay protection Resend webhook with reused timestamp POST webhook Signature verification fails (timestamp tolerance) src/__tests__/integration/security/webhook-replay.test.ts
CORS strict origin Origin evil.com API request Response 403 or preflight fails src/__tests__/integration/security/cors.test.ts
Health check exposed No auth GET /api/health Returns 200 with JSON status (public by design) src/__tests__/integration/security/health-public.test.ts
Health check no secrets No auth GET /api/health Response does NOT contain env vars, tokens, or DB credentials src/__tests__/integration/security/health-safety.test.ts
Data export authorization CLIENT requests own practice export Call exportPracticeData Returns full JSON export src/__tests__/integration/security/data-export-auth.test.ts
Data export blocked for other CLIENT requests Practice B export Call exportPracticeData Returns 403 src/__tests__/integration/security/data-export-auth.test.ts
Data deletion soft delete practiceId deleted Query Practice status = CANCELLED, name = [DELETED], tokens wiped src/__tests__/integration/security/data-deletion.test.ts
External deletion event withdrawConsent triggers Inngest Capture Inngest event Event name = compliance/external-deletion, payload correct src/__tests__/integration/security/external-deletion.test.ts
Input validation on signup password = "123", email = "not-an-email" POST /api/auth/signup ZodError with field-specific messages, no DB record created src/__tests__/integration/security/signup-validation.test.ts

Security Tests#

Test Trigger Test Failure Action
Encryption key strength App startup ENCRYPTION_KEY length ≥ 32 bytes after scrypt Refuse to start if key too short
Auth tag integrity Token storage Every encrypted field must have authTag present Reject malformed ciphertext
RBAC enforcement Every tRPC router All non-public procedures must call auth() Static code audit (AST scan)
Rate limit bypass API requests Test with X-Forwarded-For spoofing Rate limit uses secure client IP behind proxy
SQL injection via Prisma All raw queries No string concatenation in $queryRaw Lint rule blocks raw string queries
Secret leakage CI build git grep for sk-, Bearer, password patterns in source Block PR if secrets found
Dependency vulnerability Weekly npm audit or pnpm audit Auto-create PR to patch high-severity CVEs
Docker non-root Container startup USER nextjs in Dockerfile, no root processes Fail CI if root detected
TLS minimum version SSL scan TLS 1.2+ only, no weak ciphers Alert if TLS 1.1 or weak cipher enabled
CSP header presence Every page response Content-Security-Policy header present Block deploy if header missing

Success Criteria (Binary)#

  • encrypt(plaintext)decrypt(ciphertext) round-trip returns exact original plaintext
  • Encrypted token in DB is never stored as plaintext; ciphertext is 3-part base64 colon-delimited
  • Tampering with authTag or ciphertext causes decrypt to throw (authentication failure)
  • publicProcedure requires no auth; protectedProcedure requires auth; practiceProcedure requires auth + practice membership; adminProcedure requires role = ADMIN
  • CLIENT attempting adminProcedure receives 403 FORBIDDEN with no DB mutation
  • Rate limit of 100 req/min enforced globally; skill.execute limited to 10/min; content.generate limited to 5/min
  • 101st request in 1-minute window returns 429 Too Many Requests
  • Zod validation rejects invalid inputs before any DB operation
  • DOMPurify removes <script> tags and dangerous HTML from user inputs
  • Prisma parameterized queries prevent SQL injection; no raw string concatenation in $queryRaw
  • Every mutation creates an AuditLog row with action, entityType, oldValue, newValue, ipAddress, userAgent
  • Request ID is propagated to response header x-request-id and all audit logs
  • Session expires after 7 days; refreshes after 24 hours of inactivity
  • Data export returns only requesting user's own practice data (no cross-practice leakage)
  • Soft delete wipes tokens, anonymizes reviews, and preserves audit logs for 7 years
  • Health check endpoint is public but contains no secrets, tokens, or env vars
  • Webhook signature verification uses HMAC with timestamp tolerance to prevent replay attacks
  • Docker container runs as non-root nextjs user (UID 1001)
  • Content-Security-Policy header present on all page responses
  • Dependency audit passes with zero high-severity vulnerabilities

Agent Context (Pre-conditions)#

  • Required DB state: User (roles: ADMIN, CLIENT, EDITOR, VIEWER), Practice, GbpAccount, AuditLog
  • Required env vars: ENCRYPTION_KEY (32-byte), BETTER_AUTH_SECRET, RESEND_WEBHOOK_SECRET
  • Required external mocks: Resend webhook signature, tRPC context builder
  • Required packages: crypto, zod, isomorphic-dompurify, vitest, msw

Verification Commands#

# Unit tests
pnpm test:unit -- src/__tests__/unit/security/

# Integration tests
pnpm test:integration -- src/__tests__/integration/security/

# Security tests
pnpm test:security -- src/__tests__/security/

# Static audit
pnpm audit --audit-level=high
pnpm exec secretlint "**/*"

9. Mock Data Registry#

Mock Factories#

// src/__tests__/factories/report.ts
export const createMockReportData = (overrides?: Partial<ReportData>): ReportData => ({
  practiceName: "Dr. Smith Dental Clinic",
  period: "January 2025",
  scoreOverall: 78,
  scoreGbp: 82,
  scoreCitations: 76,
  scoreReviews: 85,
  scoreRankings: 70,
  scoreSeo: 88,

  gbpViews: 1240,
  gbpClicks: 340,
  gbpCalls: 45,
  gbpDirections: 28,
  gbpPostsPublished: 6,

  citationsLive: 24,
  citationsVerified: 30,
  napConsistency: 0.95,

  reviewAvg: 4.2,
  reviewsNew: 8,
  reviewsReplied: 6,
  replyRate: 0.75,

  keywordsTop10: 12,
  keywordsTop3: 3,
  rankings: [
    { keyword: "dentist in Kochi", currentRank: 3, previousRank: 5, change: -2 },
    { keyword: "dental clinic Kochi", currentRank: 7, previousRank: 8, change: -1 },
    { keyword: "root canal treatment Kochi", currentRank: 12, previousRank: 15, change: -3 },
    { keyword: "best dentist Kochi", currentRank: null, previousRank: null, change: 0 },
  ],

  actionItems: [
    "Add 3 more photos to your Google Business Profile this week.",
    "Respond to the 2 unanswered reviews from last month.",
    "Post a holiday greeting on social media to boost engagement.",
  ],

  reportUrl: "https://app.rankflow.in/dashboard/reports/report-123",
  pdfUrl: "https://rankflow-reports.s3.ap-south-1.amazonaws.com/reports/report-prac-123-January-2025.pdf",
  ...overrides,
});

// src/__tests__/factories/digest.ts
export const createMockDigestData = (overrides?: Partial<DigestData>) => ({
  practiceName: "Dr. Smith Dental Clinic",
  mapsPosition: 3,
  previousPosition: 4,
  newReviews: 4,
  reviewAvg: 4.8,
  postsPublished: 2,
  dashboardUrl: "https://app.rankflow.in/dashboard",
  ...overrides,
});

// src/__tests__/factories/practice.ts
export const createMockPractice = (overrides?: Partial<Practice>) => ({
  id: `prac_${faker.string.nanoid(8)}`,
  businessName: "Dr. Smith Dental Clinic",
  category: "DENTIST",
  plan: "STANDARD",
  status: "ACTIVE",
  directorySlug: "dr-smith-dental",
  schemaMarkup: {
    "@context": "https://schema.org",
    "@type": "MedicalBusiness",
    name: "Dr. Smith Dental Clinic",
    address: {
      "@type": "PostalAddress",
      streetAddress: "123 MG Road",
      addressLocality: "Kochi",
      addressRegion: "Kerala",
      postalCode: "682011",
      addressCountry: "IN",
    },
    telephone: "+91-98765-43210",
    medicalSpecialty: "Dentistry",
  },
  ...overrides,
});

// src/__tests__/factories/location.ts
export const createMockLocation = (overrides?: Partial<Location>) => ({
  id: `loc_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  isPrimary: true,
  address: "123 MG Road, Kochi, Kerala 682011",
  city: "Kochi",
  state: "Kerala",
  pinCode: "682011",
  phone: "+91-98765-43210",
  email: "contact@drsmithdental.com",
  hours: { mon: "09:00-18:00", tue: "09:00-18:00", sat: "09:00-14:00" },
  ...overrides,
});

// src/__tests__/factories/consent.ts
export const createMockConsentLog = (overrides?: Partial<ConsentLog>) => ({
  id: `cns_${faker.string.nanoid(8)}`,
  userId: `usr_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  consentType: "terms",
  granted: true,
  ipAddress: "192.168.1.100",
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
  grantedAt: new Date("2025-01-15T10:30:00.000Z"),
  ...overrides,
});

// src/__tests__/factories/user.ts
export const createMockUser = (overrides?: Partial<User>) => ({
  id: `usr_${faker.string.nanoid(8)}`,
  email: "dr.smith@example.com",
  name: "Dr. Smith",
  role: "CLIENT",
  createdAt: new Date(),
  ...overrides,
});

// src/__tests__/factories/gbp-account.ts
export const createMockGbpAccount = (overrides?: Partial<GbpAccount>) => ({
  id: `gbp_${faker.string.nanoid(8)}`,
  practiceId: `prac_${faker.string.nanoid(8)}`,
  accountEmail: "dr.smith@gmail.com",
  accessToken: encrypt("ya29.a0ARrdaM_test_access_token"),
  refreshToken: encrypt("1//0test_refresh_token"),
  tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
  scope: ["business.manage"],
  isActive: true,
  ...overrides,
});

Mock External Services (MSW Handlers)#

Service Endpoint Mock Response Failure Mode
Resend API POST /emails { id: "msg_123", object: "email" } 403 Invalid API Key
Resend Webhook POST /api/webhooks/resend 200 OK 401 Invalid Signature
S3 PutObject 200 OK 403 Access Denied
Google OAuth POST /token { access_token: "test", refresh_token: "test" } error=access_denied
GBP API GET /accounts/locations { locations: [{ name: "Dr. Smith Dental" }] } HTTP 429
Inngest POST /api/inngest 200 OK 500 Server Error
Better Auth Session validation { user: { id: "usr_123", role: "CLIENT" } } 401 Unauthorized
DataForSEO POST /v3/serp/google/organic/live/advanced { tasks: [{ result: [{ items: [{ position: 3 }] }] }] } 402 Payment Required
Firecrawl POST /scrape { markdown: "NAP matches" } 500 Internal Error
Composio POST /api/auth/oauth { connectionId: "conn_123" } OAuth error
Stripe/Razorpay POST /v1/subscriptions { id: "sub_123", status: "trialing" } Card declined
Claude API POST /v1/messages { content: [{ text: "Compliant content" }], usage: { input_tokens: 100 } } 429 Rate limit
OpenAI API POST /v1/chat/completions { choices: [{ message: { content: "Compliant content" } }] } 500 Server Error

10. Verification Commands#

Run All Tests for This Spec#

# 1. Unit tests
pnpm test:unit -- src/__tests__/unit/pdf/ src/__tests__/unit/email/ src/__tests__/unit/reporting/ src/__tests__/unit/compliance/ src/__tests__/unit/schema/ src/__tests__/unit/security/

# 2. Integration tests
pnpm test:integration -- src/__tests__/integration/pdf/ src/__tests__/integration/email/ src/__tests__/integration/reporting/ src/__tests__/integration/compliance/ src/__tests__/integration/schema/ src/__tests__/integration/security/

# 3. E2E tests
pnpm test:e2e -- e2e/reporting/

# 4. Compliance tests
pnpm test:compliance -- src/__tests__/compliance/

# 5. Security tests
pnpm test:security -- src/__tests__/security/

# 6. Full pipeline (CI)
pnpm test:ci

Coverage Requirements#

Test Category Minimum Coverage Target Coverage
Unit Tests 80% 90%
Integration Tests 70% 85%
E2E Tests Critical flows only All user journeys
Compliance Tests 100% of filters 100% of banned phrases + all consent paths
Security Tests 100% of RBAC routes 100% of auth procedures + all rate limits

CI Pipeline Gates#

Stage Entry Gate Exit Gate Test Command
Schema Design Prisma schema approved prisma generate succeeds pnpm prisma generate
API Router Schema exists All router integration tests pass pnpm test:integration -- routers
PDF/Email Service Mock services configured PDF pipeline + email delivery pass pnpm test:integration -- pdf email
Compliance Content filters built All compliance tests pass pnpm test:compliance
Security Auth middleware built RBAC + encryption + rate limit pass pnpm test:security
E2E Reporting All services tested Monthly report flow completes < 30s pnpm test:e2e -- reporting
Deployment All tests pass Health checks + smoke tests pass pnpm test:smoke

Agent Task Template for This Spec#

## Task: [Feature Name]
**Role**: [Compliance_Test_Specialist | Security_Engineer | Reporting_Developer]
**Guidance**: Read `docs/specs/email-reports-pdf.md`, `docs/specs/security-compliance.md`, `docs/test-specs/TEST-reporting-compliance.md`
**Context**: Mock factories in `src/__tests__/factories/`, MSW handlers in `src/__tests__/mocks/`
**Mission**:
1. Write a FAILING test first (TDD)
2. Implement the feature
3. Make the test pass
4. Run the verification command
5. Report: PASS / FAIL with evidence

**Deliverables**:
- [ ] Implementation code
- [ ] Test file(s)
- [ ] VERIFY.md with test output

End of Test Specification — RankFlow AI Reporting, Compliance & Security v1.0.0