fix(import): preserve repeated legacy mood rows

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 13:47:28 +02:00
1 parent e9507a0c93
commit 6ebc278c2c
2 files changed
+86 -48

No files matched your search

+81 -48
View File
@@ -90,6 +90,10 @@ interface ApiErrorEnvelope {
error: string;
}
function asDate(value: string | Date): Date {
return value instanceof Date ? value : new Date(value);
}
// V3 audit: /api/import POST had no rate-limit. Bulk-injection vector
// (max:10000 records per call). Now capped at 5/hour/user.
describe("POST /api/import — rate-limit guard", () => {
@@ -807,22 +811,24 @@ describe("POST /api/import — write failure classification", () => {
return {} as never;
};
vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) =>
writeMood(data as LegacyMoodWrite, committedKeys),
vi.mocked(prisma.moodEntry.create).mockImplementation(
(({ data }) =>
writeMood(data as LegacyMoodWrite, committedKeys)) as never,
);
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 {
(({ where }) => {
const composite = where.userId_date_moodLoggedAt;
const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`;
if (!committedKeys.has(key)) return Promise.resolve(null);
const isFirstDay = composite.date === "2026-05-01";
return Promise.resolve({
source: "IMPORT",
mood: isFirstDay ? "GUT" : "OKAY",
score: isFirstDay ? 4 : 3,
tags: null,
externalId: null,
} as never;
},
} as never);
}) as never,
);
const payload = {
@@ -856,21 +862,25 @@ describe("POST /api/import — write failure classification", () => {
try {
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
const committedKeys = new Set<string>();
vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => {
const key = `${data.date}:${data.moodLoggedAt.toISOString()}`;
if (committedKeys.has(key)) {
throw new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
);
}
committedKeys.add(key);
return {} as never;
});
vi.mocked(prisma.moodEntry.create).mockImplementation(
(({ data }) => {
const key = `${data.date}:${asDate(data.moodLoggedAt).toISOString()}`;
if (committedKeys.has(key)) {
return Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
),
);
}
committedKeys.add(key);
return Promise.resolve({} as never);
}) as never,
);
const firstResponse = await POST(
request({
@@ -909,8 +919,9 @@ 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;
const moodLoggedAt = asDate(
vi.mocked(prisma.moodEntry.create).mock.calls[0][0].data.moodLoggedAt,
);
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
Date.parse("2026-04-30T22:00:00.000Z"),
);
@@ -937,10 +948,10 @@ 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.getTime()).toBeGreaterThanOrEqual(
expect(new Date(moodLoggedAt).getTime()).toBeGreaterThanOrEqual(
Date.parse("2026-05-10T22:00:00.000Z"),
);
expect(moodLoggedAt.getTime()).toBeLessThan(
expect(new Date(moodLoggedAt).getTime()).toBeLessThan(
Date.parse("2026-05-10T22:05:00.000Z"),
);
} finally {
@@ -948,7 +959,7 @@ describe("POST /api/import — write failure classification", () => {
}
});
it("probes past a persisted fallback timestamp collision", async () => {
it("resolves persisted and repeated-row fallback timestamp collisions", async () => {
type StoredMood = {
date: string;
mood: string;
@@ -959,25 +970,33 @@ describe("POST /api/import — write failure classification", () => {
};
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;
});
vi.mocked(prisma.moodEntry.create).mockImplementation(
(({ data }) => {
const moodLoggedAt = asDate(data.moodLoggedAt);
const key = `${data.date}:${moodLoggedAt.toISOString()}`;
if (persisted.has(key)) {
return Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
),
);
}
persisted.set(key, { ...data, moodLoggedAt } as StoredMood);
return Promise.resolve({} as never);
}) as never,
);
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
(({ where }) => {
const composite = where.userId_date_moodLoggedAt;
const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`;
return Promise.resolve((persisted.get(key) ?? null) as never);
}) as never,
);
const firstPayload = {
moodEntries: [
@@ -1003,6 +1022,20 @@ describe("POST /api/import — write failure classification", () => {
error: null,
});
expect(persisted.size).toBe(2);
const repeatedRowResponse = await POST(
request({
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" },
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" },
],
}),
);
expect(await repeatedRowResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 2, skipped: 0 },
error: null,
});
expect(persisted.size).toBe(4);
});
it("keeps a generated timestamp stable when upserting an external ID", async () => {
+5
View File
@@ -299,6 +299,7 @@ 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) {
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
const occurrence =
@@ -314,6 +315,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
) {
const loggedAt =
e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe);
if (!e.loggedAt) {
if (reservedFallbackInstants.has(loggedAt.getTime())) continue;
reservedFallbackInstants.add(loggedAt.getTime());
}
try {
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.