From 036ec42a7ed77cd6b4ad63a1ac25c6a1d37ef21c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 11:09:36 +0000 Subject: [PATCH] fix(reports): drop stale tool readings after profile hash change On-demand generation only rewrote natal charts when birth data changed, so tarot/numerology/Seer context kept showing readings for the old chart. Clear mismatched catalog fields on regenerate and treat hash-mismatched reports as not ready in the client and Main Seer packer. Co-authored-by: Andy Oliver Rozario --- app/api/profile/generate-mystical/route.ts | 11 +++ hooks/useComprehensiveMysticalProfile.tsx | 13 ++- lib/mainSeerContext.ts | 34 ++++++-- lib/mainSeerTools.ts | 35 ++++++-- lib/onDemandToolReports.ts | 26 +++++- lib/profileGenerationOrchestrator.ts | 4 + lib/staleCatalogReports.ts | 96 ++++++++++++++++++++++ lib/toolReportReadiness.ts | 24 ++++++ tests/integration/profile-generate.test.ts | 10 +++ tests/integration/report-readiness.test.ts | 26 ++++++ tests/unit/mainSeerContext.test.ts | 49 +++++++++++ tests/unit/mainSeerTools.test.ts | 13 +++ tests/unit/staleCatalogReports.test.ts | 64 +++++++++++++++ 13 files changed, 388 insertions(+), 17 deletions(-) create mode 100644 lib/staleCatalogReports.ts create mode 100644 tests/unit/mainSeerContext.test.ts create mode 100644 tests/unit/staleCatalogReports.test.ts diff --git a/app/api/profile/generate-mystical/route.ts b/app/api/profile/generate-mystical/route.ts index 8db1a2ca..7be70a6d 100644 --- a/app/api/profile/generate-mystical/route.ts +++ b/app/api/profile/generate-mystical/route.ts @@ -28,6 +28,7 @@ import { checkRateLimitWithOptionalFirestore } from '@/lib/rateLimitFirestore'; import { acquireMysticalGenerationLock, getMysticalLockRuntimeStatus } from '@/lib/generationLock'; import type { PersistedToolStatusMap } from '@/lib/mysticalStageB'; import { + clearStaleCatalogReports, generateAndPersistToolReports, NATAL_CHART_SLUGS, } from '@/lib/onDemandToolReports'; @@ -408,6 +409,16 @@ export async function POST(request: NextRequest) { natalTools: NATAL_CHART_SLUGS, }); + try { + await clearStaleCatalogReports({ + uid, + profileHash: newHash, + keepSlugs: NATAL_CHART_SLUGS, + }); + } catch (staleErr) { + devLog.warn('[generate-mystical] Failed to clear stale catalog reports', staleErr, 'generate-mystical'); + } + let natalReady: string[] = []; let natalFailed: string[] = []; try { diff --git a/hooks/useComprehensiveMysticalProfile.tsx b/hooks/useComprehensiveMysticalProfile.tsx index 9c98e37f..c03e75a6 100644 --- a/hooks/useComprehensiveMysticalProfile.tsx +++ b/hooks/useComprehensiveMysticalProfile.tsx @@ -1,7 +1,8 @@ 'use client' import { useMysticalProfileContext } from '@/contexts/MysticalProfileContext' -import { classifyToolReportState } from '@/lib/toolReportReadiness' +import { useAuth } from '@/hooks/use-auth' +import { classifyToolReportState, reportMatchesProfileHash } from '@/lib/toolReportReadiness' import type { PersistedToolStatus } from '@/lib/mysticalStageB' export type { ComprehensiveMysticalProfile } from '@/contexts/MysticalProfileContext' @@ -14,6 +15,7 @@ export function useComprehensiveMysticalProfile() { export function useToolReport(toolSlug: string) { const { profile, loading, error, isReportsStale, refreshProfile } = useMysticalProfileContext() + const { userProfile } = useAuth() const p = profile as Record | null // Resolve from both shapes: top-level (e.g. profile.western) or toolReports[slug].data const toolReports = p != null ? (p.toolReports as Record | undefined) : undefined @@ -26,18 +28,25 @@ export function useToolReport(toolSlug: string) { const toolStatusMap = (p != null ? (p.toolStatus as Record | undefined) : undefined) ?? {} const persistedStatus = toolStatusMap[toolSlug] const reportState = classifyToolReportState(report, toolSlug) + const profileHash = + (typeof userProfile?.profileDataHash === 'string' && userProfile.profileDataHash) || + (typeof p?.profileDataHash === 'string' ? p.profileDataHash : undefined) + const matchesCurrentHash = reportMatchesProfileHash(report, profileHash) // Prefer live classification — stale toolStatus "ready" must not unlock blank shells. let state = persistedStatus?.state ?? reportState if (state === 'ready' && reportState !== 'ready') { state = reportState } + if (state === 'ready' && !matchesCurrentHash) { + state = 'pending' + } const updatedAt = persistedStatus?.updatedAt ?? persistedStatus?.generatedAt const generatedAt = persistedStatus?.generatedAt return { report: report ?? undefined, loading, error, - hasReport: report !== undefined && report !== null && state === 'ready', + hasReport: report !== undefined && report !== null && state === 'ready' && matchesCurrentHash, reportState, reportStatus: persistedStatus, reportStateResolved: state, diff --git a/lib/mainSeerContext.ts b/lib/mainSeerContext.ts index c629fe5a..901d685c 100644 --- a/lib/mainSeerContext.ts +++ b/lib/mainSeerContext.ts @@ -5,7 +5,11 @@ import { getDocument } from '@/lib/firebase-admin' import type { UserProfile } from '@/lib/firebase' -import { ALL_TOOL_SLUGS, isReadyToolReport, summarizeToolReadiness } from '@/lib/toolReportReadiness' +import { + ALL_TOOL_SLUGS, + isCurrentReadyToolReport, + summarizeToolReadiness, +} from '@/lib/toolReportReadiness' import { wantsDeeperSeerAnswer } from '@/lib/seerChatVoice' const SLICE_CHARS_DEFAULT = 1_800 @@ -99,14 +103,25 @@ export function compactReportSlice(value: unknown, maxChars: number): string { function resolveStoredReport( comprehensive: Record, slug: string, + profileHash?: string, ): Record | null { const nested = comprehensive.toolReports as Record | undefined const val = comprehensive[slug] ?? nested?.[slug]?.data if (!val || typeof val !== 'object' || Array.isArray(val)) return null - if (!isReadyToolReport(val, slug)) return null + if (!isCurrentReadyToolReport(val, profileHash, slug)) return null return val as Record } +function resolveProfileHash( + profile: UserProfile | null, + comprehensive: Record, +): string | undefined { + if (typeof profile?.profileDataHash === 'string' && profile.profileDataHash) { + return profile.profileDataHash + } + return typeof comprehensive.profileDataHash === 'string' ? comprehensive.profileDataHash : undefined +} + export function formatReadyToolsIndex( readySlugs: readonly string[], pendingSlugs: readonly string[], @@ -139,14 +154,19 @@ export async function loadMainSeerContext(params: { const wantsDeep = wantsDeeperSeerAnswer(question) const comprehensive = ((await getDocument('comprehensiveMysticalProfiles', userId)) || {}) as Record + const profileHash = resolveProfileHash(profile, comprehensive) const readiness = summarizeToolReadiness(comprehensive, ALL_TOOL_SLUGS) - const readySlugs = ALL_TOOL_SLUGS.filter((slug) => !readiness.pendingToolSlugs.includes(slug)) + const readySlugs = ALL_TOOL_SLUGS.filter( + (slug) => resolveStoredReport(comprehensive, slug, profileHash) != null, + ) + const pendingSlugs = ALL_TOOL_SLUGS.filter((slug) => !readySlugs.includes(slug)) + const droppedStaleReady = readiness.pendingToolSlugs.length < pendingSlugs.length const selectedSlugs = pickRelevantToolSlugs(question, readySlugs, { deeper: wantsDeep }) const sliceChars = wantsDeep ? SLICE_CHARS_DEEP : SLICE_CHARS_DEFAULT const slices = selectedSlugs .map((slug) => { - const report = resolveStoredReport(comprehensive, slug) + const report = resolveStoredReport(comprehensive, slug, profileHash) if (!report) return null const text = compactReportSlice(report, sliceChars) return text ? `### ${slug}\n${text}` : null @@ -154,7 +174,9 @@ export async function loadMainSeerContext(params: { .filter((block): block is string => Boolean(block)) let seerMaster = ((await getDocument('seerMaster', userId)) || null) as Record | null - if (!seerMaster || Object.keys(seerMaster).length === 0) { + if (droppedStaleReady) { + seerMaster = null + } else if (!seerMaster || Object.keys(seerMaster).length === 0) { const nested = comprehensive.seerMaster if (nested && typeof nested === 'object' && !Array.isArray(nested)) { seerMaster = nested as Record @@ -164,7 +186,7 @@ export async function loadMainSeerContext(params: { return { identityText: buildIdentityDossier(profile), seerMasterText: formatSeerMasterForPrompt(seerMaster), - readyIndexText: formatReadyToolsIndex(readySlugs, readiness.pendingToolSlugs), + readyIndexText: formatReadyToolsIndex(readySlugs, pendingSlugs), reportSlicesText: slices.length > 0 ? `Relevant stored reports for this question:\n${slices.join('\n\n')}` diff --git a/lib/mainSeerTools.ts b/lib/mainSeerTools.ts index 452296bc..96aea2ec 100644 --- a/lib/mainSeerTools.ts +++ b/lib/mainSeerTools.ts @@ -4,8 +4,8 @@ import { getDocument } from '@/lib/firebase-admin'; import { searchKnowledge, formatKnowledgeForPrompt } from '@/lib/knowledgeLoader'; import { ALL_TOOL_SLUGS, + isCurrentReadyToolReport, isReadyToolReport, - summarizeToolReadiness, } from '@/lib/profileGenerationOrchestrator'; import { truncateToTokenBudget } from '@/lib/aiTokenBudget'; @@ -90,13 +90,18 @@ export function isMainSeerToolName(name: string): name is MainSeerToolName { function resolveToolReport( profile: Record, toolSlug: string, + profileHash?: string, ): Record | null { const nested = profile.toolReports as Record | undefined; const val = profile[toolSlug] ?? nested?.[toolSlug]?.data; - if (!val || typeof val !== 'object' || !isReadyToolReport(val)) return null; + if (!val || typeof val !== 'object' || !isCurrentReadyToolReport(val, profileHash, toolSlug)) return null; return val as Record; } +function profileHashFromStored(profile: Record): string | undefined { + return typeof profile.profileDataHash === 'string' ? profile.profileDataHash : undefined; +} + function compactJson(value: unknown, maxChars: number): string { const raw = JSON.stringify(value); if (raw.length <= maxChars) return raw; @@ -112,16 +117,30 @@ export async function executeMainSeerTool( case 'list_ready_tools': { const profile = ((await getDocument('comprehensiveMysticalProfiles', userId)) || {}) as Record; - const readiness = summarizeToolReadiness(profile, ALL_TOOL_SLUGS); - const readyTools = ALL_TOOL_SLUGS.filter((slug) => !readiness.pendingToolSlugs.includes(slug)); + const profileHash = profileHashFromStored(profile); + const readyTools = ALL_TOOL_SLUGS.filter( + (slug) => resolveToolReport(profile, slug, profileHash) != null, + ); + const pendingToolSlugs = ALL_TOOL_SLUGS.filter((slug) => !readyTools.includes(slug)); return { readyTools, - readyCount: readiness.readyToolsCount, - pendingToolSlugs: readiness.pendingToolSlugs, - allReportsReady: readiness.allReportsReady, + readyCount: readyTools.length, + pendingToolSlugs, + allReportsReady: pendingToolSlugs.length === 0, }; } case 'get_seer_master_summary': { + const storedProfile = ((await getDocument('comprehensiveMysticalProfiles', userId)) || + {}) as Record; + const profileHash = profileHashFromStored(storedProfile); + const hasStaleReadyReport = ALL_TOOL_SLUGS.some((slug) => { + const nested = storedProfile.toolReports as Record | undefined; + const val = storedProfile[slug] ?? nested?.[slug]?.data; + return isReadyToolReport(val, slug) && resolveToolReport(storedProfile, slug, profileHash) == null; + }); + if (hasStaleReadyReport) { + return { found: false, message: 'Seer Master summary is stale after a profile change.' }; + } const seerMaster = ((await getDocument('seerMaster', userId)) || null) as Record< string, unknown @@ -141,7 +160,7 @@ export async function executeMainSeerTool( } const profile = ((await getDocument('comprehensiveMysticalProfiles', userId)) || {}) as Record; - const report = resolveToolReport(profile, toolSlug); + const report = resolveToolReport(profile, toolSlug, profileHashFromStored(profile)); if (!report) { return { found: false, toolSlug, message: 'Report not ready or not found.' }; } diff --git a/lib/onDemandToolReports.ts b/lib/onDemandToolReports.ts index 2848af1b..179d37f4 100644 --- a/lib/onDemandToolReports.ts +++ b/lib/onDemandToolReports.ts @@ -1,6 +1,7 @@ import 'server-only'; -import { getDocument, setDocument } from '@/lib/firebase-admin'; +import { deleteDocument, getDocument, setDocument } from '@/lib/firebase-admin'; +import { buildStaleCatalogClearPatch } from '@/lib/staleCatalogReports'; import type { UserProfile } from '@/lib/firebase'; import { ALL_TOOL_SLUGS, @@ -147,6 +148,29 @@ export async function generateAndPersistToolReports(params: { }; } +/** + * Drop catalog reports (and Seer Master) that belong to a previous profile hash. + * Natal charts for the new hash are written afterwards by persistOnDemandToolReports. + */ +export async function clearStaleCatalogReports(params: { + uid: string; + profileHash: string; + keepSlugs?: readonly string[]; +}): Promise { + const { uid, profileHash, keepSlugs = [] } = params; + const existingProfile = ((await getDocument('comprehensiveMysticalProfiles', uid)) || + {}) as Record; + const patch = buildStaleCatalogClearPatch(existingProfile, profileHash, keepSlugs); + if (patch) { + await setDocument('comprehensiveMysticalProfiles', uid, patch); + } + const seerMaster = await getDocument('seerMaster', uid); + if (seerMaster) { + await deleteDocument('seerMaster', uid); + } + clearCachedDivinationData(uid); +} + export function storedReportMatchesHash( report: unknown, profileHash: string, diff --git a/lib/profileGenerationOrchestrator.ts b/lib/profileGenerationOrchestrator.ts index 688c608b..f98fc5a1 100644 --- a/lib/profileGenerationOrchestrator.ts +++ b/lib/profileGenerationOrchestrator.ts @@ -25,7 +25,9 @@ import { classifyToolReportState, getCoreToolSlugsCore10, hasDisplayableReportSubstance, + isCurrentReadyToolReport, isReadyToolReport, + reportMatchesProfileHash, summarizeToolReadiness, type ReportReadinessState, } from '@/lib/toolReportReadiness'; @@ -35,7 +37,9 @@ export { classifyToolReportState, getCoreToolSlugsCore10, hasDisplayableReportSubstance, + isCurrentReadyToolReport, isReadyToolReport, + reportMatchesProfileHash, summarizeToolReadiness, }; export type { ReportReadinessState }; diff --git a/lib/staleCatalogReports.ts b/lib/staleCatalogReports.ts new file mode 100644 index 00000000..4264de97 --- /dev/null +++ b/lib/staleCatalogReports.ts @@ -0,0 +1,96 @@ +/** + * Pure helpers to drop catalog reports that belong to a previous profile hash. + * Used when Generate Full Report commits a new natal hash so other tools are + * not shown or packed as if they still match the new birth data. + */ + +import { ALL_TOOL_SLUGS } from '@/lib/toolReportReadiness'; + +/** Synthesis / derived keys that must not survive a profile-hash change. */ +export const STALE_CATALOG_EXTRA_KEYS = [ + 'interpretations', + 'seerMaster', + 'vedicAstroNumerology', + 'astroNumerology', +] as const; + +function generationKey(report: unknown): string | null { + if (!report || typeof report !== 'object' || Array.isArray(report)) return null; + const key = (report as { generationIdempotencyKey?: unknown }).generationIdempotencyKey; + return typeof key === 'string' && key.length > 0 ? key : null; +} + +function reportBelongsToHash(report: unknown, profileHash: string): boolean { + const key = generationKey(report); + return key === profileHash; +} + +function nestedReportData(entry: unknown): unknown { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const data = (entry as { data?: unknown }).data; + return data !== undefined ? data : entry; +} + +/** + * Build a merge patch that nulls catalog fields whose generation key does not + * match `profileHash`. Missing keys are treated as stale on hash change. + */ +export function buildStaleCatalogClearPatch( + existingProfile: Record, + profileHash: string, + keepSlugs: readonly string[] = [], + now = Date.now(), +): Record | null { + const keep = new Set(keepSlugs); + const patch: Record = {}; + + for (const slug of ALL_TOOL_SLUGS) { + if (keep.has(slug)) continue; + const report = existingProfile[slug]; + if (report == null) continue; + if (reportBelongsToHash(report, profileHash)) continue; + patch[slug] = null; + } + + const nested = existingProfile.toolReports; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + const nextNested = { ...(nested as Record) }; + let nestedChanged = false; + for (const slug of ALL_TOOL_SLUGS) { + if (keep.has(slug) || nextNested[slug] == null) continue; + if (reportBelongsToHash(nestedReportData(nextNested[slug]), profileHash)) continue; + delete nextNested[slug]; + nestedChanged = true; + } + if (nestedChanged) patch.toolReports = nextNested; + } + + const existingStatus = existingProfile.toolStatus; + if (existingStatus && typeof existingStatus === 'object' && !Array.isArray(existingStatus)) { + const nextStatus: Record = { ...(existingStatus as Record) }; + let statusChanged = false; + for (const slug of ALL_TOOL_SLUGS) { + if (keep.has(slug) || patch[slug] !== null) continue; + if (nextStatus[slug] == null) continue; + const prev = + typeof nextStatus[slug] === 'object' && nextStatus[slug] !== null + ? (nextStatus[slug] as Record) + : {}; + nextStatus[slug] = { + ...prev, + state: 'pending', + updatedAt: now, + error: null, + unchanged: false, + }; + statusChanged = true; + } + if (statusChanged) patch.toolStatus = nextStatus; + } + + for (const key of STALE_CATALOG_EXTRA_KEYS) { + if (existingProfile[key] != null) patch[key] = null; + } + + return Object.keys(patch).length > 0 ? patch : null; +} diff --git a/lib/toolReportReadiness.ts b/lib/toolReportReadiness.ts index 08fd3882..ce23387b 100644 --- a/lib/toolReportReadiness.ts +++ b/lib/toolReportReadiness.ts @@ -103,6 +103,30 @@ export function isReadyToolReport(report: unknown, toolSlug?: string): boolean { return classifyToolReportState(report, toolSlug) === 'ready'; } +/** + * Whether a stored report belongs to the current profile hash. + * Reports without `generationIdempotencyKey` are treated as current so legacy + * rows are not mass-regenerated. A present key that does not match is stale. + */ +export function reportMatchesProfileHash( + report: unknown, + profileHash: string | null | undefined, +): boolean { + if (!profileHash) return true; + if (!report || typeof report !== 'object' || Array.isArray(report)) return false; + const key = (report as { generationIdempotencyKey?: unknown }).generationIdempotencyKey; + if (typeof key !== 'string' || key.length === 0) return true; + return key === profileHash; +} + +export function isCurrentReadyToolReport( + report: unknown, + profileHash: string | null | undefined, + toolSlug?: string, +): boolean { + return isReadyToolReport(report, toolSlug) && reportMatchesProfileHash(report, profileHash); +} + /** Catalog slugs. Generate commits natal charts; other tools run on visit. */ export const ALL_TOOL_SLUGS = [ // Highest-priority unlocks first (critical user wow path) diff --git a/tests/integration/profile-generate.test.ts b/tests/integration/profile-generate.test.ts index 470f7f04..6eda50c4 100644 --- a/tests/integration/profile-generate.test.ts +++ b/tests/integration/profile-generate.test.ts @@ -45,6 +45,7 @@ const mockGenerateAllReports = jest.fn(); const mockClearCachedDivinationData = jest.fn(); const mockTryResumeMysticalStageB = jest.fn(); const mockGenerateAndPersistToolReports = jest.fn(); +const mockClearStaleCatalogReports = jest.fn(); const mockEnsureAdminAvailable = jest.fn(); const mockAfterOutputs: Array> = []; @@ -118,6 +119,7 @@ jest.mock('@/lib/mysticalStageB', () => ({ jest.mock('@/lib/onDemandToolReports', () => ({ NATAL_CHART_SLUGS: ['vedic', 'western'], generateAndPersistToolReports: (...args: unknown[]) => mockGenerateAndPersistToolReports(...args), + clearStaleCatalogReports: (...args: unknown[]) => mockClearStaleCatalogReports(...args), })); jest.mock('@/lib/rateLimitFirestore', () => ({ @@ -182,6 +184,7 @@ describe('Profile generate-mystical API', () => { failedSlugs: [], toolReports: {}, }); + mockClearStaleCatalogReports.mockResolvedValue(undefined); }); afterEach(() => { @@ -337,6 +340,12 @@ describe('Profile generate-mystical API', () => { expect(data.decision).toBe('rerun'); expect(data.decisionReason).toBe('profile_hash_changed'); expect(mockGenerateAndPersistToolReports).toHaveBeenCalled(); + expect(mockClearStaleCatalogReports).toHaveBeenCalledWith( + expect.objectContaining({ + uid, + keepSlugs: ['vedic', 'western'], + }), + ); expect(mockTryResumeMysticalStageB).not.toHaveBeenCalled(); }); @@ -367,6 +376,7 @@ describe('Profile generate-mystical API', () => { expect(data.decision).toBe('skipped'); expect(data.decisionReason).toBe('unchanged_hash_committed'); expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled(); + expect(mockClearStaleCatalogReports).not.toHaveBeenCalled(); expect(mockSetDocument).not.toHaveBeenCalledWith( 'generationJobs', uid, diff --git a/tests/integration/report-readiness.test.ts b/tests/integration/report-readiness.test.ts index 2c181d62..edba8567 100644 --- a/tests/integration/report-readiness.test.ts +++ b/tests/integration/report-readiness.test.ts @@ -5,7 +5,9 @@ import { classifyToolReportState, + isCurrentReadyToolReport, isReadyToolReport, + reportMatchesProfileHash, summarizeToolReadiness, ALL_TOOL_SLUGS, } from '@/lib/toolReportReadiness'; @@ -21,6 +23,30 @@ describe('Report readiness contract', () => { expect(isReadyToolReport({ placeholder: true })).toBe(false); }); + it('treats a mismatched generationIdempotencyKey as stale but keeps unkeyed legacy reports', () => { + expect(reportMatchesProfileHash({ planets: [{ name: 'Sun' }] }, 'hash-new')).toBe(true); + expect( + reportMatchesProfileHash( + { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'hash-new' }, + 'hash-new', + ), + ).toBe(true); + expect( + reportMatchesProfileHash( + { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'hash-old' }, + 'hash-new', + ), + ).toBe(false); + expect(isCurrentReadyToolReport({ planets: [{ name: 'Sun' }] }, 'hash-new', 'vedic')).toBe(true); + expect( + isCurrentReadyToolReport( + { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'hash-old' }, + 'hash-new', + 'vedic', + ), + ).toBe(false); + }); + it('rejects meta-only and reason-only shells as pending', () => { expect( classifyToolReportState({ diff --git a/tests/unit/mainSeerContext.test.ts b/tests/unit/mainSeerContext.test.ts new file mode 100644 index 00000000..8530e38d --- /dev/null +++ b/tests/unit/mainSeerContext.test.ts @@ -0,0 +1,49 @@ +import { getDocument } from '@/lib/firebase-admin'; +import { loadMainSeerContext } from '@/lib/mainSeerContext'; +import type { UserProfile } from '@/lib/firebase'; + +jest.mock('@/lib/firebase-admin', () => ({ + getDocument: jest.fn(), +})); + +const mockGetDocument = getDocument as jest.MockedFunction; + +describe('loadMainSeerContext hash freshness', () => { + const profile = { + uid: 'user-1', + birthDate: '1992-06-20', + profileDataHash: 'new-hash', + } as UserProfile; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('omits stored reports stamped with a previous profile hash', async () => { + mockGetDocument.mockImplementation(async (collection: string) => { + if (collection === 'comprehensiveMysticalProfiles') { + return { + profileDataHash: 'new-hash', + vedic: { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'new-hash' }, + tarot: { cards: [{ name: 'The Fool' }], generationIdempotencyKey: 'old-hash' }, + }; + } + if (collection === 'seerMaster') { + return { core_identity: ['stale synthesis'] }; + } + return null; + }); + + const packed = await loadMainSeerContext({ + userId: 'user-1', + question: 'What does my tarot say about love?', + profile, + }); + + expect(packed.readySlugs).toContain('vedic'); + expect(packed.readySlugs).not.toContain('tarot'); + expect(packed.reportSlicesText).toContain('vedic'); + expect(packed.reportSlicesText).not.toContain('The Fool'); + expect(packed.seerMasterText).toContain('not generated yet'); + }); +}); diff --git a/tests/unit/mainSeerTools.test.ts b/tests/unit/mainSeerTools.test.ts index 7a9cabb3..cae31d7f 100644 --- a/tests/unit/mainSeerTools.test.ts +++ b/tests/unit/mainSeerTools.test.ts @@ -50,6 +50,19 @@ describe('mainSeerTools', () => { expect(String(result.report)).toContain('Leo'); }); + it('get_tool_report hides reports stamped for a previous profile hash', async () => { + mockGetDocument.mockResolvedValue({ + profileDataHash: 'new-hash', + tarot: { cards: [{ name: 'The Fool' }], generationIdempotencyKey: 'old-hash' }, + }); + + const listed = await executeMainSeerTool('list_ready_tools', {}, 'user-1'); + expect(listed.readyTools).not.toContain('tarot'); + + const result = await executeMainSeerTool('get_tool_report', { toolSlug: 'tarot' }, 'user-1'); + expect(result.found).toBe(false); + }); + it('get_tool_report rejects invalid slug', async () => { const result = await executeMainSeerTool('get_tool_report', { toolSlug: 'not-a-real-tool' }, 'user-1'); expect(result.error).toMatch(/Invalid or missing toolSlug/); diff --git a/tests/unit/staleCatalogReports.test.ts b/tests/unit/staleCatalogReports.test.ts new file mode 100644 index 00000000..8c532ee0 --- /dev/null +++ b/tests/unit/staleCatalogReports.test.ts @@ -0,0 +1,64 @@ +import { ALL_TOOL_SLUGS } from '@/lib/toolReportReadiness'; +import { buildStaleCatalogClearPatch } from '@/lib/staleCatalogReports'; + +describe('buildStaleCatalogClearPatch', () => { + it('nulls catalog reports whose generation key does not match the new hash', () => { + const patch = buildStaleCatalogClearPatch( + { + profileDataHash: 'new-hash', + vedic: { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'old-hash' }, + tarot: { cards: [{ name: 'The Fool' }], generationIdempotencyKey: 'old-hash' }, + interpretations: { personality: { overview: 'old' } }, + seerMaster: { core_identity: ['old'] }, + toolStatus: { + tarot: { state: 'ready', generatedAt: 1 }, + }, + }, + 'new-hash', + ['vedic', 'western'], + 1_700_000_000_000, + ); + + expect(patch).not.toBeNull(); + expect(patch?.vedic).toBeUndefined(); + expect(patch?.tarot).toBeNull(); + expect(patch?.interpretations).toBeNull(); + expect(patch?.seerMaster).toBeNull(); + expect(patch?.toolStatus).toEqual( + expect.objectContaining({ + tarot: expect.objectContaining({ state: 'pending', updatedAt: 1_700_000_000_000 }), + }), + ); + }); + + it('keeps reports already stamped with the new hash', () => { + const patch = buildStaleCatalogClearPatch( + { + tarot: { cards: [{ name: 'The Magician' }], generationIdempotencyKey: 'new-hash' }, + }, + 'new-hash', + ); + expect(patch?.tarot).toBeUndefined(); + }); + + it('returns null when there is nothing to clear', () => { + expect(buildStaleCatalogClearPatch({}, 'new-hash')).toBeNull(); + }); + + it('drops nested toolReports entries that do not match the new hash', () => { + const patch = buildStaleCatalogClearPatch( + { + toolReports: { + numerology: { data: { lifePathNumber: 7, generationIdempotencyKey: 'old-hash' } }, + western: { data: { sunSign: 'Leo', generationIdempotencyKey: 'new-hash' } }, + }, + }, + 'new-hash', + ['western'], + ); + expect(patch?.toolReports).toEqual({ + western: { data: { sunSign: 'Leo', generationIdempotencyKey: 'new-hash' } }, + }); + expect(ALL_TOOL_SLUGS).toContain('numerology'); + }); +});