mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(api): add bulk soft-delete for measurements and mood
Add `POST /api/measurements/bulk-delete` and `POST /api/mood-entries/bulk-delete`,
mirroring the bulk-insert conventions (`withIdempotency`, per-user rate
limit, response envelope). Body is `{ ids: string[] }`, capped at 200.
Soft-delete only, ownership-scoped: a single `updateMany` pins `userId` +
`deletedAt: null` and sets `deletedAt` + bumps `syncVersion`, matching the
single-DELETE iOS tombstone contract. A forged or foreign id is a silent
no-op — the returned `deleted` count reflects owned rows only, never a 404
existence leak.
Side effects fan out efficiently: measurements collapse the deleted rows to
the unique `(type, day)` set before recomputing rollups (one recompute per
type-day, not per row) and invalidate the affected status-insight caches;
mood collapses to the unique day set for its rollup recompute. Both bust the
per-user read caches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0d69611bbf
commit
ca262d9839
7 files changed
+984
No files matched your search
@@ -1508,6 +1508,18 @@ paths:
|
||||
type: string
|
||||
enum:
|
||||
- rollup
|
||||
- in: query
|
||||
name: sourceEq
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- MANUAL
|
||||
- WITHINGS
|
||||
- IMPORT
|
||||
- APPLE_HEALTH
|
||||
- COMPUTED
|
||||
- WHOOP
|
||||
- FITBIT
|
||||
- in: query
|
||||
name: groupBy
|
||||
schema:
|
||||
@@ -1698,6 +1710,31 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/bulk-delete:
|
||||
post:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Bulk soft-delete measurements
|
||||
description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the
|
||||
single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited
|
||||
`measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is
|
||||
the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementsBulkDeleteRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Bulk delete processed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementsBulkDeleteResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/sleep/night:
|
||||
get:
|
||||
tags:
|
||||
@@ -3998,6 +4035,19 @@ components:
|
||||
- externalIds
|
||||
description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped
|
||||
(cross-user 404 guard).
|
||||
MeasurementsBulkDeleteRequest:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- ids
|
||||
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
|
||||
DeviceRegisterRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -7012,6 +7062,33 @@ components:
|
||||
required:
|
||||
- deletedCount
|
||||
additionalProperties: false
|
||||
MeasurementsBulkDeleteResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementsBulkDeleteResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementsBulkDeleteResult:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
SleepNightResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* v1.15.13 — POST /api/measurements/bulk-delete.
|
||||
*
|
||||
* Asserts the ownership-scoped soft-delete contract: only owned, not-
|
||||
* already-tombstoned rows are touched; a forged / foreign id is a silent
|
||||
* no-op (no 404 existence leak); the rollup recompute collapses to the
|
||||
* unique `(type, day)` set instead of firing one recompute per row; the
|
||||
* >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
// keep the real collapse so the test exercises the actual fan-out logic
|
||||
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
// `vi.resetAllMocks()` wipes the factory defaults; the best-effort
|
||||
// post-delete hooks must be re-stubbed to resolved promises (the route
|
||||
// awaits the rollup recompute and `.catch()`es the insight invalidate).
|
||||
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
|
||||
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/bulk-delete", () => {
|
||||
it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => {
|
||||
// Caller asked for 3 ids but only 2 are owned + live.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
// updateMany is ownership-scoped + filters tombstones — a forged id
|
||||
// never 404s, it's just excluded from the count.
|
||||
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["m1", "m2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(call.data).toMatchObject({
|
||||
syncVersion: { increment: 1 },
|
||||
});
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
|
||||
// 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day,
|
||||
// 1× PULSE same day as the first → 3 distinct (type, day) keys.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
|
||||
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
|
||||
|
||||
// 4 rows in, 3 distinct (type, day) recomputes out.
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1", "foreign-2"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty id array with 422", async () => {
|
||||
const res = await POST(postReq({ ids: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["m1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `POST /api/measurements/bulk-delete` — multi-select soft-delete for the
|
||||
* measurements management list (v1.15.13).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
|
||||
*
|
||||
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
|
||||
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
|
||||
* forged / foreign id is a silent no-op (never a 404 existence leak): the
|
||||
* mutation `where` pins `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { deleted: <number of rows tombstoned> }
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:bulk-delete:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk deletions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bulkDeleteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.bulk-delete.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
// Read the soon-to-be-tombstoned rows first so we know which
|
||||
// `(type, day)` buckets need a rollup recompute and which types need
|
||||
// a status-insight cache drop. Ownership-scoped + `deletedAt: null` so
|
||||
// a forged / foreign / already-tombstoned id is excluded here exactly
|
||||
// as it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.measurement.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { type: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
// so a forged id for another user is silently a no-op; `deletedAt: null`
|
||||
// keeps the count honest (an already-tombstoned id doesn't re-count).
|
||||
const { count } = await prisma.measurement.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("measurement.delete.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.delete.bulk" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches so subsequent reads reflect the deletions.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
|
||||
// recomputing so a 200-row delete spanning one day fires ~1 recompute
|
||||
// per type, not 200 (mirrors the batch-insert path). Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const keys = collapseToTypeDayKeys(
|
||||
affected.map((row) => ({
|
||||
type: row.type as MeasurementType,
|
||||
measuredAt: row.measuredAt,
|
||||
})),
|
||||
);
|
||||
try {
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[measurements] bulk-delete rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the deletion
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the reduced history. Fire-and-forget: never blocks the delete.
|
||||
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
|
||||
if (affectedTypes.length > 0) {
|
||||
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
|
||||
console.warn(
|
||||
"[measurements] bulk-delete status-insight invalidate failed",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* v1.15.13 — POST /api/mood-entries/bulk-delete.
|
||||
*
|
||||
* Asserts the ownership-scoped soft-delete contract: only owned, not-
|
||||
* already-tombstoned rows are touched; a forged / foreign id is a silent
|
||||
* no-op (no 404 existence leak); the mood rollup recompute collapses to
|
||||
* the unique day set; the >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
// `vi.resetAllMocks()` wipes the factory default; the route awaits the
|
||||
// best-effort mood-rollup recompute, so re-stub it to a resolved promise.
|
||||
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk-delete", () => {
|
||||
it("soft-deletes only owned, non-tombstoned rows and bumps syncVersion", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["e1", "e2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(call.data).toMatchObject({ syncVersion: { increment: 1 } });
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique day set, not per-row", async () => {
|
||||
// 3 deleted rows across 2 distinct UTC days → 2 recomputes.
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T22:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c"] }));
|
||||
expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMood).not.toHaveBeenCalled();
|
||||
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `e${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["e1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* `POST /api/mood-entries/bulk-delete` — multi-select soft-delete for the
|
||||
* mood management list (v1.15.13).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 mood-entry ids, scoped to the caller.
|
||||
*
|
||||
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
|
||||
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
|
||||
* forged / foreign id is a silent no-op (never a 404 existence leak): the
|
||||
* mutation `where` pins `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { deleted: <number of rows tombstoned> }
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:bulk-delete:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk deletions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bulkDeleteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.bulk-delete.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
// Read the soon-to-be-tombstoned rows first so we know which days need
|
||||
// a mood-rollup recompute. Ownership-scoped + `deletedAt: null` so a
|
||||
// forged / foreign / already-tombstoned id is excluded here exactly as
|
||||
// it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
// so a forged id for another user is silently a no-op; `deletedAt: null`
|
||||
// keeps the count honest (an already-tombstoned id doesn't re-count).
|
||||
const { count } = await prisma.moodEntry.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("mood.delete.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.delete.bulk" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — collapse the deleted rows to the unique
|
||||
// `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning
|
||||
// one day fires one recompute, not one per row. Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const touchedDayStarts = new Set<number>();
|
||||
for (const row of affected) {
|
||||
const d = row.moodLoggedAt;
|
||||
touchedDayStarts.add(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
}
|
||||
@@ -579,6 +579,26 @@ const deleteByExternalIdsResponse = z
|
||||
})
|
||||
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
|
||||
|
||||
// v1.15.13 — multi-select bulk soft-delete for the measurements
|
||||
// management list. Ids are scoped to the caller; forged / foreign ids
|
||||
// are silent no-ops (no existence leak). Soft-delete (tombstone), so the
|
||||
// rows surface in the `/api/sync/changes` delta feed.
|
||||
const bulkDeleteMeasurementsRequest = z
|
||||
.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(200),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementsBulkDeleteRequest",
|
||||
description:
|
||||
"1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).",
|
||||
});
|
||||
|
||||
const bulkDeleteMeasurementsResponse = z
|
||||
.object({
|
||||
deleted: z.number().int().nonnegative(),
|
||||
})
|
||||
.meta({ id: "MeasurementsBulkDeleteResult" });
|
||||
|
||||
const measurementResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -3444,6 +3464,34 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurements/bulk-delete": {
|
||||
post: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Bulk soft-delete measurements",
|
||||
description:
|
||||
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: bulkDeleteMeasurementsRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Bulk delete processed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
bulkDeleteMeasurementsResponse,
|
||||
"MeasurementsBulkDeleteResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sleep/night": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* v1.15.13 — bulk soft-delete + source filter, real-Postgres integration.
|
||||
*
|
||||
* Covers, against the real testcontainer:
|
||||
* - `POST /api/measurements/bulk-delete` soft-deletes only the caller's
|
||||
* rows; a foreign id is a silent no-op (no existence leak), bumps
|
||||
* `syncVersion`, sets `deletedAt`, and the count reflects owned rows
|
||||
* only.
|
||||
* - Idempotency replay returns the same `deleted` count without
|
||||
* re-mutating (a second distinct call would re-bump syncVersion).
|
||||
* - `POST /api/mood-entries/bulk-delete` mirrors the same contract.
|
||||
* - `sourceEq` (measurements) + `source` (mood) narrow the list reads.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_A = "user-bulkdel-a";
|
||||
const USER_B = "user-bulkdel-b";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function seedUser(id: string, username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: { id, username, email: `${username}@example.test`, timezone: "UTC" },
|
||||
});
|
||||
}
|
||||
|
||||
async function loginAs(id: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await seedUser(USER_A, "bulkdel-a");
|
||||
await seedUser(USER_B, "bulkdel-b");
|
||||
});
|
||||
|
||||
function postReq(path: string, body: unknown, idempotencyKey?: string) {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/measurements/bulk-delete (real Postgres)", () => {
|
||||
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const now = new Date();
|
||||
const mine = await prisma.measurement.createManyAndReturn({
|
||||
data: [
|
||||
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
|
||||
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "MANUAL", measuredAt: new Date(now.getTime() - 1000) },
|
||||
],
|
||||
});
|
||||
const theirs = await prisma.measurement.create({
|
||||
data: { userId: USER_B, type: "WEIGHT", value: 70, unit: "kg", source: "MANUAL", measuredAt: now },
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
||||
const res = await POST(
|
||||
postReq("/api/measurements/bulk-delete", {
|
||||
ids: [mine[0].id, mine[1].id, theirs.id],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
// Only the two owned rows count; the foreign id is a silent no-op.
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
const minedAfter = await prisma.measurement.findMany({
|
||||
where: { id: { in: [mine[0].id, mine[1].id] } },
|
||||
});
|
||||
for (const row of minedAfter) {
|
||||
expect(row.deletedAt).not.toBeNull();
|
||||
// Measurement.syncVersion defaults to 1; one bulk delete bumps it to 2.
|
||||
expect(row.syncVersion).toBe(2);
|
||||
}
|
||||
// The other user's row is untouched (default syncVersion = 1).
|
||||
const theirsAfter = await prisma.measurement.findUnique({
|
||||
where: { id: theirs.id },
|
||||
});
|
||||
expect(theirsAfter!.deletedAt).toBeNull();
|
||||
expect(theirsAfter!.syncVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("idempotency replay returns the cached count without re-mutating", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const row = await prisma.measurement.create({
|
||||
data: { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: new Date() },
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
||||
const key = "idem-meas-bulk-1";
|
||||
|
||||
const first = await POST(
|
||||
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
expect(((await first.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
|
||||
|
||||
// Replay with the SAME key → cached body, same count, no re-mutation.
|
||||
const replay = await POST(
|
||||
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
|
||||
);
|
||||
expect(replay.status).toBe(200);
|
||||
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
||||
expect(((await replay.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
|
||||
|
||||
// Measurement.syncVersion defaults to 1; the single real delete bumped
|
||||
// it to 2 and the replay did NOT re-run the mutation (so it stays 2).
|
||||
const after = await prisma.measurement.findUnique({ where: { id: row.id } });
|
||||
expect(after!.syncVersion).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects a >200-id batch with 422", async () => {
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`);
|
||||
const res = await POST(postReq("/api/measurements/bulk-delete", { ids }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk-delete (real Postgres)", () => {
|
||||
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const mine = await prisma.moodEntry.createManyAndReturn({
|
||||
data: [
|
||||
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
|
||||
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "MANUAL", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
|
||||
],
|
||||
});
|
||||
const theirs = await prisma.moodEntry.create({
|
||||
data: { userId: USER_B, date: "2026-01-01", mood: "LAUSIG", score: 1, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T09:00:00Z"), tz: "UTC" },
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk-delete/route");
|
||||
const res = await POST(
|
||||
postReq("/api/mood-entries/bulk-delete", {
|
||||
ids: [mine[0].id, mine[1].id, theirs.id],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { data: { deleted: number } }).data.deleted).toBe(2);
|
||||
|
||||
const after = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: [mine[0].id, mine[1].id] } },
|
||||
});
|
||||
for (const row of after) {
|
||||
expect(row.deletedAt).not.toBeNull();
|
||||
expect(row.syncVersion).toBe(1);
|
||||
}
|
||||
const theirsAfter = await prisma.moodEntry.findUnique({
|
||||
where: { id: theirs.id },
|
||||
});
|
||||
expect(theirsAfter!.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("source filter narrows the list reads (real Postgres)", () => {
|
||||
it("measurements sourceEq returns only the matching source", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const now = new Date();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
|
||||
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 1000) },
|
||||
{ userId: USER_A, type: "WEIGHT", value: 82, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 2000) },
|
||||
],
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { GET } = await import("@/app/api/measurements/route");
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/measurements?sourceEq=WITHINGS"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ source: string }>;
|
||||
meta: { total: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.measurements).toHaveLength(2);
|
||||
expect(body.data.measurements.every((m) => m.source === "WITHINGS")).toBe(true);
|
||||
expect(body.data.meta.total).toBe(2);
|
||||
});
|
||||
|
||||
it("mood source returns only the matching source", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.moodEntry.createMany({
|
||||
data: [
|
||||
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
|
||||
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "TELEGRAM", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
|
||||
],
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { GET } = await import("@/app/api/mood-entries/route");
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/mood-entries?source=TELEGRAM"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ source: string }>; meta: { total: number } };
|
||||
};
|
||||
expect(body.data.entries).toHaveLength(1);
|
||||
expect(body.data.entries[0].source).toBe("TELEGRAM");
|
||||
expect(body.data.meta.total).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user