Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/api/profile/generate-catalog-batch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export async function POST(request: NextRequest) {
string,
unknown
>;
const before = summarizeToolReadiness(stored, ALL_TOOL_SLUGS);
const profileHash = calculateProfileDataHash(userProfile);
const before = summarizeToolReadiness(stored, ALL_TOOL_SLUGS, profileHash);
const toolStatus = (stored.toolStatus as PersistedToolStatusMap | undefined) ?? {};
if (before.allReportsReady) {
return NextResponse.json({
Expand Down Expand Up @@ -103,7 +104,6 @@ export async function POST(request: NextRequest) {
});
}

const profileHash = calculateProfileDataHash(userProfile);
const result = await generateAndPersistToolReports({
uid,
profile: { ...userProfile, uid },
Expand All @@ -112,7 +112,7 @@ export async function POST(request: NextRequest) {
skipVedicComprehensive: false,
});

const readiness = result.readiness ?? summarizeToolReadiness(stored, ALL_TOOL_SLUGS);
const readiness = result.readiness ?? summarizeToolReadiness(stored, ALL_TOOL_SLUGS, profileHash);
const next = selectRunnableCatalogSlugs(
readiness.pendingToolSlugs,
result.toolStatus ?? toolStatus,
Expand Down
7 changes: 4 additions & 3 deletions app/api/profile/generate-mystical/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export async function POST(request: NextRequest) {
if (hashMatches) {
const stored = await getDocument('comprehensiveMysticalProfiles', uid);
const storedProfile = (stored || {}) as Record<string, unknown>;
const readiness = summarizeToolReadiness(storedProfile, ALL_TOOL_SLUGS);
const readiness = summarizeToolReadiness(storedProfile, ALL_TOOL_SLUGS, effectiveHash);
if (readiness.allReportsReady) {
const auditId = await writeRegenDecisionTelemetry(uid, {
event: 'mystical_regen_skipped_unchanged',
Expand Down Expand Up @@ -503,7 +503,7 @@ export async function POST(request: NextRequest) {
const storedAfterNatal =
((await getDocument('comprehensiveMysticalProfiles', uid)) || {}) as Record<string, unknown>;
const readiness =
natalReadiness ?? summarizeToolReadiness(storedAfterNatal, ALL_TOOL_SLUGS);
natalReadiness ?? summarizeToolReadiness(storedAfterNatal, ALL_TOOL_SLUGS, newHash);
await setDocument('users', uid, {
mysticalProfileGenerated: true,
mysticalProfileGeneratedAt: Date.now(),
Expand Down Expand Up @@ -613,7 +613,8 @@ export async function GET(request: NextRequest) {
const generationJobStatus = typeof generationJob?.status === 'string' ? generationJob.status : null;
const lockRuntime = getMysticalLockRuntimeStatus(lock, mysticalLockStaleMs());
const generated = Boolean(user?.mysticalProfileGenerated) || Boolean(profileDoc);
const readiness = summarizeToolReadiness(profile, ALL_TOOL_SLUGS);
const currentHash = typeof user?.profileDataHash === 'string' ? user.profileDataHash : undefined;
const readiness = summarizeToolReadiness(profile, ALL_TOOL_SLUGS, currentHash);
const lastHeartbeatAt = typeof generationJob?.lastHeartbeatAt === 'number' ? generationJob.lastHeartbeatAt : null;
const runningHeartbeatStale =
generationJobStatus === 'running' &&
Expand Down
2 changes: 1 addition & 1 deletion lib/onDemandToolReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function persistOnDemandToolReports(params: {

profilePatch.toolStatus = toolStatus;
const mergedProfile = { ...existingProfile, ...profilePatch };
const readiness = summarizeToolReadiness(mergedProfile, ALL_TOOL_SLUGS);
const readiness = summarizeToolReadiness(mergedProfile, ALL_TOOL_SLUGS, profileHash);
await setDocument('comprehensiveMysticalProfiles', uid, profilePatch);
await setDocument('users', uid, {
mysticalProfileGenerated: true,
Expand Down
20 changes: 19 additions & 1 deletion lib/toolReportReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,27 @@ export function getCoreToolSlugsCore10(): string[] {
return [...CORE10_TOOL_SLUGS];
}

/**
* When a current profile hash is supplied, reports keyed to a *different*
* generationIdempotencyKey are treated as pending so a birth-data regen
* refills the catalog instead of keeping the previous natal's readings.
* Reports with no key stay ready (legacy rows) unless Generate clears them.
*/
export function reportMatchesCurrentProfileHash(
report: unknown,
currentProfileHash: string | undefined,
): boolean {
if (!currentProfileHash) return true;
if (!report || typeof report !== 'object') return true;
const key = (report as Record<string, unknown>).generationIdempotencyKey;
if (typeof key !== 'string' || key.length === 0) return true;
return key === currentProfileHash;
}

export function summarizeToolReadiness(
profile: Record<string, unknown> | null | undefined,
toolSlugs: readonly string[] = ALL_TOOL_SLUGS,
currentProfileHash?: string,
): { readyToolsCount: number; pendingToolSlugs: string[]; allReportsReady: boolean } {
if (!profile) {
return {
Expand All @@ -184,7 +202,7 @@ export function summarizeToolReadiness(
const pendingToolSlugs: string[] = [];
for (const slug of toolSlugs) {
const report = profile[slug] ?? toolReports?.[slug]?.data;
if (isReadyToolReport(report, slug)) {
if (isReadyToolReport(report, slug) && reportMatchesCurrentProfileHash(report, currentProfileHash)) {
readyToolsCount += 1;
} else {
pendingToolSlugs.push(slug);
Expand Down
36 changes: 36 additions & 0 deletions tests/integration/generate-catalog-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,42 @@ describe('generate-catalog-batch API', () => {
expect(data.allReportsReady).toBe(false);
});

it('generates hash-mismatched tools instead of treating the old catalog as complete', async () => {
mockGetDocument.mockImplementation((collection: string) => {
if (collection === 'users') return Promise.resolve({ uid, mysticalProfileGenerated: true });
if (collection === 'comprehensiveMysticalProfiles') {
const stored = allToolsDisplayableProfile();
stored.vedic = {
...displayableReportForSlug('vedic'),
generationIdempotencyKey: 'hash-1',
};
stored.western = {
...displayableReportForSlug('western'),
generationIdempotencyKey: 'hash-1',
};
for (const slug of ALL_TOOL_SLUGS) {
if (slug === 'vedic' || slug === 'western') continue;
stored[slug] = {
...displayableReportForSlug(slug),
generationIdempotencyKey: 'old-hash',
};
}
return Promise.resolve(stored);
}
return Promise.resolve({});
});
const res = await callBatch();
const data = await res.json();
expect(res.status).toBe(200);
expect(mockGenerateAndPersistToolReports).toHaveBeenCalledWith(
expect.objectContaining({
toolSlugs: ['hellenistic', 'esotericAstrology'],
}),
);
expect(data.generatedSlugs).toEqual(['hellenistic', 'esotericAstrology']);
expect(data.allReportsReady).toBe(false);
});

it('skips exhausted failed slugs and generates the next runnable tools', async () => {
mockGetDocument.mockImplementation((collection: string) => {
if (collection === 'users') return Promise.resolve({ uid, mysticalProfileGenerated: true });
Expand Down
89 changes: 89 additions & 0 deletions tests/integration/profile-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,42 @@ describe('Profile generate-mystical API', () => {
expect(data.pendingToolSlugs.length).toBeGreaterThan(0);
expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled();
});

it('returns fill_catalog when hash matches but stored tools belong to a previous hash', async () => {
const profile = { ...baseProfile };
const hash = calculateProfileDataHash(profile);
const staleCatalog = allToolsDisplayableProfile();
for (const slug of ALL_TOOL_SLUGS) {
staleCatalog[slug] = {
...(staleCatalog[slug] as Record<string, unknown>),
generationIdempotencyKey: 'previous-hash',
};
}
mockGetDocument.mockImplementation((collection: string) => {
if (collection === 'users') {
return Promise.resolve({
...profile,
mysticalProfileGenerated: true,
profileDataHash: hash,
});
}
if (collection === 'generationLocks') return Promise.resolve(null);
if (collection === 'comprehensiveMysticalProfiles') {
return Promise.resolve(staleCatalog);
}
return Promise.resolve(undefined);
});

const res = await callGenerate();
const data = await res.json();
expect(res.status).toBe(200);
expect(data.alreadyGenerated).toBe(true);
expect(data.decision).toBe('fill_catalog');
expect(data.decisionReason).toBe('unchanged_hash_catalog_incomplete');
expect(data.allReportsReady).toBe(false);
expect(data.pendingToolSlugs).toContain('tarot');
expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled();
});
});

describe('Auth and validation', () => {
Expand Down Expand Up @@ -740,6 +776,59 @@ describe('Profile generate-mystical API', () => {
expect(mockTryResumeMysticalStageB).not.toHaveBeenCalled();
});

it('does not mark the catalog complete when stored tools belong to a previous hash', async () => {
const currentHash = calculateProfileDataHash(baseProfile);
const staleCatalog = allToolsDisplayableProfile();
staleCatalog.vedic = {
...(staleCatalog.vedic as Record<string, unknown>),
generationIdempotencyKey: currentHash,
};
staleCatalog.western = {
...(staleCatalog.western as Record<string, unknown>),
generationIdempotencyKey: currentHash,
};
for (const slug of ALL_TOOL_SLUGS) {
if (slug === 'vedic' || slug === 'western') continue;
staleCatalog[slug] = {
...(staleCatalog[slug] as Record<string, unknown>),
generationIdempotencyKey: 'previous-hash',
};
}
mockGetDocument.mockImplementation((collection: string) => {
if (collection === 'users') {
return Promise.resolve({
...baseProfile,
mysticalProfileGenerated: true,
profileDataHash: currentHash,
allReportsReady: true,
pendingToolSlugs: [],
});
}
if (collection === 'generationLocks') {
return Promise.resolve({ status: 'completed', phase: 'completed', updatedAt: Date.now() });
}
if (collection === 'comprehensiveMysticalProfiles') {
return Promise.resolve(staleCatalog);
}
return Promise.resolve(undefined);
});

const res = await callGenerationStatus();
const data = await res.json();
expect(res.status).toBe(200);
expect(data.allReportsReady).toBe(false);
expect(data.completed).toBe(false);
expect(data.pendingToolSlugs).toContain('tarot');
expect(data.pendingToolSlugs).not.toContain('vedic');
expect(mockSetDocument).toHaveBeenCalledWith(
'users',
uid,
expect.objectContaining({
allReportsReady: false,
}),
);
});

it('reconciles user allReportsReady false when profile shows all tools ready', async () => {
const allReadyProfile = allToolsDisplayableProfile();
mockGetDocument.mockImplementation((collection: string) => {
Expand Down
28 changes: 28 additions & 0 deletions tests/integration/report-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,32 @@ describe('Report readiness contract', () => {
expect(summary.pendingToolSlugs).not.toContain('nameAnalysis');
expect(summary.pendingToolSlugs).not.toContain('vastu');
});

it('treats generationIdempotencyKey mismatches as pending when a current hash is given', () => {
const profile = {
vedic: { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'hash-new' },
western: { planets: [{ name: 'Moon' }], generationIdempotencyKey: 'hash-old' },
tarot: { profile: { birthCard: { name: 'The Fool' } } },
} as Record<string, unknown>;
const summary = summarizeToolReadiness(profile, ALL_TOOL_SLUGS, 'hash-new');
expect(summary.pendingToolSlugs).not.toContain('vedic');
expect(summary.pendingToolSlugs).toContain('western');
expect(summary.pendingToolSlugs).not.toContain('tarot');
expect(summary.allReportsReady).toBe(false);
});

it('does not treat missing keys as stale when summarizing for a current hash', () => {
const slugs = ['dreamSymbols', 'ogham'] as const;
const keyed = {
dreamSymbols: { reading: 'ok', generationIdempotencyKey: 'hash-1' },
ogham: { reading: 'ok', generationIdempotencyKey: 'hash-1' },
};
const unkeyed = {
dreamSymbols: { reading: 'ok' },
ogham: { reading: 'ok' },
};
expect(summarizeToolReadiness(keyed, slugs, 'hash-1').allReportsReady).toBe(true);
expect(summarizeToolReadiness(unkeyed, slugs, 'hash-1').allReportsReady).toBe(true);
expect(summarizeToolReadiness(keyed, slugs, 'hash-2').allReportsReady).toBe(false);
});
});
Loading