mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(import): resolve legacy mood timestamp collisions
This commit is contained in:
1 parent
cd6b5f0cc6
commit
7eb3b91925
2 files changed
+225
-82
No files matched your search
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
moodEntry: { create: vi.fn(), upsert: vi.fn() },
|
||||
moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -77,6 +77,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined);
|
||||
vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined);
|
||||
encryptNoteMock.mockImplementation((note: string | null) =>
|
||||
@@ -809,6 +810,20 @@ describe("POST /api/import — write failure classification", () => {
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) =>
|
||||
writeMood(data as LegacyMoodWrite, committedKeys),
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
|
||||
async ({ where }) => {
|
||||
const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`;
|
||||
if (!committedKeys.has(key)) return null;
|
||||
const isFirstDay = where.userId_date_moodLoggedAt.date === "2026-05-01";
|
||||
return {
|
||||
source: "IMPORT",
|
||||
mood: isFirstDay ? "GUT" : "OKAY",
|
||||
score: isFirstDay ? 4 : 3,
|
||||
tags: null,
|
||||
externalId: null,
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const payload = {
|
||||
moodEntries: [
|
||||
@@ -896,18 +911,100 @@ describe("POST /api/import — write failure classification", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
|
||||
.data.moodLoggedAt;
|
||||
expect(moodLoggedAt.toISOString().slice(0, 10)).toBe("2026-05-01");
|
||||
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
Date.parse("2026-05-01T00:00:00.000Z"),
|
||||
Date.parse("2026-04-30T22:00:00.000Z"),
|
||||
);
|
||||
expect(moodLoggedAt.getTime()).toBeLessThan(
|
||||
Date.parse("2026-05-01T00:05:00.000Z"),
|
||||
Date.parse("2026-04-30T22:05:00.000Z"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the current Berlin day shortly after local midnight", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T22:30:00.000Z"));
|
||||
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
moodEntries: [{ date: "2026-05-11", mood: "GUT", score: 4 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
|
||||
.data.moodLoggedAt;
|
||||
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
Date.parse("2026-05-10T22:00:00.000Z"),
|
||||
);
|
||||
expect(moodLoggedAt.getTime()).toBeLessThan(
|
||||
Date.parse("2026-05-10T22:05:00.000Z"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("probes past a persisted fallback timestamp collision", async () => {
|
||||
type StoredMood = {
|
||||
date: string;
|
||||
mood: string;
|
||||
score: number;
|
||||
tags: string | null;
|
||||
externalId?: string | null;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
|
||||
const persisted = new Map<string, StoredMood>();
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => {
|
||||
const key = `${data.date}:${data.moodLoggedAt.toISOString()}`;
|
||||
if (persisted.has(key)) {
|
||||
throw new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["userId", "date", "moodLoggedAt"] },
|
||||
},
|
||||
);
|
||||
}
|
||||
persisted.set(key, data as StoredMood);
|
||||
return {} as never;
|
||||
});
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(async ({ where }) => {
|
||||
const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`;
|
||||
return (persisted.get(key) ?? null) as never;
|
||||
});
|
||||
|
||||
const firstPayload = {
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" },
|
||||
],
|
||||
};
|
||||
const collidingPayload = {
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" },
|
||||
],
|
||||
};
|
||||
|
||||
await POST(request(firstPayload));
|
||||
const collisionResponse = await POST(request(collidingPayload));
|
||||
const retryResponse = await POST(request(collidingPayload));
|
||||
|
||||
expect(await collisionResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 1, skipped: 0 },
|
||||
error: null,
|
||||
});
|
||||
expect(await retryResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 0, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
expect(persisted.size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a generated timestamp stable when upserting an external ID", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
+124
-78
@@ -33,6 +33,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
// type. Previous hardcoded subset silently dropped 4 of 11 types
|
||||
@@ -65,6 +67,16 @@ const measurementSchema = z
|
||||
"Wert ausserhalb des plausiblen Bereichs",
|
||||
);
|
||||
|
||||
function fallbackMoodDateStart(date: string): Date | null {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
if (!year || !month || !day) return null;
|
||||
const instant = zonedWallClockToUtc(
|
||||
{ year, month, day, hour: 0, minute: 0 },
|
||||
DEFAULT_TIMEZONE,
|
||||
);
|
||||
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
|
||||
}
|
||||
|
||||
const moodEntrySchema = z
|
||||
.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
@@ -84,14 +96,8 @@ const moodEntrySchema = z
|
||||
})
|
||||
.superRefine((entry, ctx) => {
|
||||
if (entry.loggedAt) return;
|
||||
const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`);
|
||||
const isCalendarDate =
|
||||
!Number.isNaN(dateStart) &&
|
||||
new Date(dateStart).toISOString().slice(0, 10) === entry.date;
|
||||
if (
|
||||
!isCalendarDate ||
|
||||
!isPlausibleEntryInstant(new Date(dateStart))
|
||||
) {
|
||||
const dateStart = fallbackMoodDateStart(entry.date);
|
||||
if (!dateStart || !isPlausibleEntryInstant(dateStart)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["date"],
|
||||
@@ -115,20 +121,18 @@ function fallbackMoodIdentity(entry: MoodEntryInput): string {
|
||||
function stableFallbackMoodInstant(
|
||||
entry: MoodEntryInput,
|
||||
occurrence: number,
|
||||
reservedInstants: Set<number>,
|
||||
probe: number,
|
||||
): Date {
|
||||
const digest = createHash("sha256")
|
||||
.update(`${fallbackMoodIdentity(entry)}:${occurrence}`)
|
||||
.digest();
|
||||
let offset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`);
|
||||
let instant = dateStart + offset;
|
||||
while (reservedInstants.has(instant)) {
|
||||
offset = (offset + 1) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
instant = dateStart + offset;
|
||||
}
|
||||
reservedInstants.add(instant);
|
||||
return new Date(instant);
|
||||
const initialOffset =
|
||||
digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const offset =
|
||||
(initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const dateStart = fallbackMoodDateStart(entry.date);
|
||||
if (!dateStart) throw new Error("Invalid mood date");
|
||||
return new Date(dateStart.getTime() + offset);
|
||||
}
|
||||
|
||||
const importSchema = z.object({
|
||||
@@ -295,74 +299,116 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Import mood entries
|
||||
if (!writeFailed && data.moodEntries?.length) {
|
||||
const fallbackOccurrences = new Map<string, number>();
|
||||
const reservedFallbackInstants = new Set<number>();
|
||||
for (const e of data.moodEntries) {
|
||||
try {
|
||||
let loggedAt = e.loggedAt;
|
||||
if (!loggedAt) {
|
||||
const identity = fallbackMoodIdentity(e);
|
||||
const occurrence = fallbackOccurrences.get(identity) ?? 0;
|
||||
fallbackOccurrences.set(identity, occurrence + 1);
|
||||
loggedAt = stableFallbackMoodInstant(
|
||||
e,
|
||||
occurrence,
|
||||
reservedFallbackInstants,
|
||||
);
|
||||
}
|
||||
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
|
||||
const occurrence =
|
||||
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
|
||||
if (identity !== null) {
|
||||
fallbackOccurrences.set(identity, occurrence + 1);
|
||||
}
|
||||
|
||||
if (e.externalId) {
|
||||
// v1.12.1 — idempotent re-import keyed on the source-stable id.
|
||||
// A second import of the same export updates the row in place
|
||||
// rather than skipping it as a duplicate, so an upstream edit
|
||||
// (re-scored mood, added tag) is reflected.
|
||||
await prisma.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
for (
|
||||
let probe = 0;
|
||||
probe < ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
probe++
|
||||
) {
|
||||
const loggedAt =
|
||||
e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe);
|
||||
try {
|
||||
if (e.externalId) {
|
||||
// v1.12.1 — idempotent re-import keyed on the source-stable id.
|
||||
// A second import of the same export updates the row in place
|
||||
// rather than skipping it as a duplicate, so an upstream edit
|
||||
// (re-scored mood, added tag) is reflected.
|
||||
await prisma.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
stats.moodEntries++;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (!isP2002(err)) {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
if (e.loggedAt) {
|
||||
stats.skipped++;
|
||||
break;
|
||||
}
|
||||
if (!e.externalId) {
|
||||
try {
|
||||
const existing = await prisma.moodEntry.findUnique({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId,
|
||||
date: e.date,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
source: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
tags: true,
|
||||
externalId: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
existing?.source === "IMPORT" &&
|
||||
existing.mood === e.mood &&
|
||||
existing.score === e.score &&
|
||||
existing.tags === (e.tags || null) &&
|
||||
(existing.externalId ?? null) === null
|
||||
) {
|
||||
stats.skipped++;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) {
|
||||
writeFailed = true;
|
||||
}
|
||||
}
|
||||
stats.moodEntries++;
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
if (writeFailed) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user