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)#
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)#
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)#
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)#
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
5. Consent Logging#
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)#
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)#
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)#
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)#
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