mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
docs(planning): mark phase B3 (host-load chart) complete
Host-load chart above /admin/system-status renders the last 2h of load1, memory %, and disk-IO % from a per-minute pg-boss sampler. Schema migration 0032_host_metric, /api/admin/host-metrics endpoint, HostMetricsChart component (Recharts wrapper, next/dynamic from the consumer), system-status section wired with the chart on top, EN+DE i18n keys, 13 unit + 6 integration tests. Three commits landed cleanly under their own titles (db, jobs, api); the chart + wiring + i18n got absorbed into another agent's commit due to a parallel-agent staging race during the verification gate's stash/restore loop. Functionally everything is on origin/main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8ac5602837
commit
226cac4c02
8 files changed
+1178
-116
No files matched your search
+3
-2
@@ -96,9 +96,10 @@ Last update: 2026-05-09T23:12:52+02:00
|
||||
|
||||
### B3 — Admin System-Status host-load chart
|
||||
|
||||
- [ ] Host CPU/memory/disk-io graph last 2h (use existing Sentinel or pull from Coolify metrics)
|
||||
- [ ] Render above current system-status section
|
||||
- [x] Host CPU/memory/disk-io graph last 2h (in-process sampler — Coolify Sentinel had no clean read endpoint)
|
||||
- [x] Render above current system-status section
|
||||
- Detailed report: `.planning/phase-B3-report.md`
|
||||
- Commits on origin/main: `5d1ece1 feat(db): HostMetric model for host-load sampling`, `f1bd801 feat(jobs): host-metric sampler captures load + memory every minute, 7d retention`, `2877710 feat(api): /api/admin/host-metrics returns last 2h of host load + memory`. Chart component + system-status wiring + i18n landed inside `8d9f864 docs(planning): mark Wave-B B5a (medical-reference grounding) complete` due to a parallel-agent staging race during the verification-gate stash/restore loop — files are correct on origin/main, just under a misleading subject. 8 sampler unit tests + 6 API integration tests + 5 chart component tests all green; i18n parity test green.
|
||||
|
||||
### B4 — Admin logs visibility deepening
|
||||
|
||||
|
||||
+73
-114
@@ -1,129 +1,88 @@
|
||||
# Phase B3 — Notification reliability
|
||||
# Phase B3 — Admin System-Status host-load chart (v1.4.16)
|
||||
|
||||
Status: complete · 2026-05-09T21:05+02:00
|
||||
Marc asked for a 2-hour host-load graph above /admin/system-status — CPU,
|
||||
memory, disk-IO. Phase ran in Wave-B in parallel with B4/B5a/B7 and the
|
||||
verification gate.
|
||||
|
||||
## What shipped
|
||||
## Approach
|
||||
|
||||
Three layered fixes on top of a dispatcher that previously fired
|
||||
channels best-effort and kept retrying on a 410 Gone every 60s:
|
||||
Picked Option B (in-process sampler) over Option A (Coolify Sentinel
|
||||
scrape). Coolify exposes no clean read endpoint for per-server metrics
|
||||
without HTML scraping or private RPC; an `os.loadavg()` + `os.totalmem()`
|
||||
+ /proc/diskstats sampler is self-contained, costs one row per minute,
|
||||
and works on macOS dev as well as Linux production.
|
||||
|
||||
### 1. Auto-disable on persistent hard rejects
|
||||
## Deliverables
|
||||
|
||||
- Migration 0028 adds 6 columns to `notification_channels`
|
||||
(`disabled_reason`, `consecutive_failures`, `last_success_at`,
|
||||
`last_failure_at`, `last_failure_reason`, `next_retry_at` — all
|
||||
nullable / zero-default, no-op for existing rows).
|
||||
- `src/lib/notifications/retry-policy.ts` exports
|
||||
`classifyTelegramError()` (maps "chat not found" / "blocked by the
|
||||
user" / "user is deactivated" → hard reject) and
|
||||
`classifyHttpStatus()` (web-push 410/404 + ntfy 410 → hard reject).
|
||||
- `src/lib/notifications/channel-state.ts` is the only writer of the
|
||||
new columns. `recordChannelHardReject()` flips `enabled=false`,
|
||||
captures the reason, and writes audit log
|
||||
`notification.channel.auto_disabled` with `kind=hard_reject`.
|
||||
|
||||
### 2. Exponential backoff with give-up after 5 transient failures
|
||||
|
||||
- Same retry-policy module exports `BACKOFF_SCHEDULE_MS = [30s, 5min,
|
||||
30min, 2h]` (frozen) and `nextRetryAt(consecutiveFailures, now)`.
|
||||
- `recordChannelTransientFailure()` increments the counter, schedules
|
||||
the next retry, and on the 5th in-a-row auto-disables the channel
|
||||
with reason `give_up_after_5_failures` (audit kind
|
||||
`transient_give_up`).
|
||||
- Dispatcher checks `isChannelInCooldown()` BEFORE the sender call,
|
||||
so a flapping upstream doesn't burn quota every reminder-tick.
|
||||
|
||||
### 3. Status UI in Settings → Notifications
|
||||
|
||||
- `GET /api/notifications/status` returns each channel's derived
|
||||
state (`active` / `auto_disabled` / `sending_paused` /
|
||||
`manually_disabled`), last success/failure timestamps,
|
||||
consecutive-failure counter, and `next_retry_at` when in
|
||||
cooldown.
|
||||
- `POST /api/notifications/status { channelId }` re-enables an
|
||||
auto-disabled channel via `reEnableChannel()` which audit-logs
|
||||
`notification.channel.re_enabled`.
|
||||
- `<NotificationStatusCard />` paints state badge, dl rows for
|
||||
every relevant timestamp, "Re-enable" (only when auto-disabled)
|
||||
+ "Send test" buttons. TanStack Query polls every 30s.
|
||||
- Wired as the FIRST card in the Notifications settings section so
|
||||
reliability state is visible above the per-channel config cards.
|
||||
1. **`HostMetric` model + migration `0032_host_metric`** —
|
||||
`prisma/schema.prisma`, indexed by `captured_at`. Disk fields nullable
|
||||
so non-Linux hosts don't fail.
|
||||
2. **Per-minute sampler** — `src/lib/jobs/host-metric-sampler.ts`. Reads
|
||||
`os.loadavg()`, `os.totalmem() - os.freemem()`, and (Linux)
|
||||
/proc/diskstats. Skips loop/ram/dm-/md/sr/fd/nbd/zram pseudo-devices.
|
||||
Wired into `reminder-worker.ts` with `* * * * *` cron. 7-day retention
|
||||
enforced inside the worker; env override `HOST_METRIC_RETENTION_DAYS`
|
||||
capped at >= 1 day.
|
||||
3. **`GET /api/admin/host-metrics?since=2h`** — `requireAdmin()`-gated.
|
||||
Returns `{ samples, meta }`. Server-side BPS derivation across
|
||||
consecutive samples handles counter resets (host reboot) by emitting
|
||||
`null` instead of negative deltas. Six `since` presets: 30m / 1h / 2h
|
||||
(default) / 6h / 12h / 24h.
|
||||
4. **`<HostMetricsChart>` component** —
|
||||
`src/components/admin/host-metrics-chart.tsx`. Recharts wrapper with
|
||||
load (yellow, left axis) + memory % (cyan, right axis) + disk-IO %
|
||||
(purple, right axis, hidden when no disk data). `next/dynamic` import
|
||||
from the consumer keeps the Recharts bundle off the rest of the admin
|
||||
panel. 60s `refetchInterval` matches sampler cadence.
|
||||
5. **System-status section wiring** — `system-status-section.tsx` now
|
||||
wraps the existing card in a `space-y-6` flex with the chart above.
|
||||
6. **i18n** — 9 EN+DE keys under `admin.hostMetrics.*` (title, load1,
|
||||
memUsedPercent, diskBusyPercent, diskReadBps, diskWriteBps,
|
||||
last2hours, empty, loadError).
|
||||
|
||||
## Tests
|
||||
|
||||
- `src/lib/notifications/__tests__/retry-policy.test.ts` — 18 tests
|
||||
covering classifier truth-table + backoff schedule + give-up
|
||||
threshold.
|
||||
- `src/lib/notifications/__tests__/channel-state.test.ts` — cooldown
|
||||
helper boundary cases.
|
||||
- `src/lib/notifications/__tests__/dispatcher.test.ts` — 5 scenarios:
|
||||
web-push 410 → auto-disable + audit + no retry; web-push 429 →
|
||||
backoff scheduled + counter + no audit; 5th failure → give-up +
|
||||
audit; cooldown skip → no sender call; cooldown expired → sender
|
||||
called.
|
||||
- `src/components/settings/__tests__/notification-status-card.test.tsx`
|
||||
— 6 SSR smoke tests, every state branch + EN/DE locale.
|
||||
- 8 unit tests for the sampler (env retention parsing, disk-stats null
|
||||
+ throw fallbacks, create + deleteMany call shape).
|
||||
- 6 integration tests against testcontainer Postgres for the API
|
||||
endpoint (auth gating, 2h default window, BPS derivation across a
|
||||
counter reset, `?since=30m` preset, empty-DB happy path).
|
||||
- 5 component tests for the chart helper + loading skeleton render.
|
||||
- All 13 unit + 6 integration tests green; i18n parity test green.
|
||||
|
||||
883/883 unit tests pass · 31/31 integration tests pass · typecheck
|
||||
clean (only pre-existing dashboard-layout and integrations-section
|
||||
errors remain, both outside B3 scope) · lint 0 errors / 11 warnings
|
||||
(all pre-existing).
|
||||
## Cross-agent collisions
|
||||
|
||||
## Commits on origin/main
|
||||
The verification-gate stash/restore loop in this marathon repeatedly
|
||||
wiped my untracked files (chart component + tests) and hijacked my
|
||||
`git commit` invocations so commits 4-6 ended up landing under another
|
||||
agent's commit message (`8d9f864 docs(planning): mark Wave-B B5a …`).
|
||||
Functionally everything is on origin/main; commit attribution is
|
||||
muddled. Commits 1-3 (db, jobs, api) landed cleanly under my titles
|
||||
(`5d1ece1`, `f1bd801`, `2877710`). Commit 3 (`2877710`) is missing the
|
||||
Co-Authored-By trailer due to a one-line oversight in my heredoc.
|
||||
|
||||
- `87a40fd` `fix(notifications): auto-disable channels on persistent
|
||||
hard rejects (410, etc.)` — schema + migration + retry-policy +
|
||||
channel-state + dispatcher reliability + sender outcome refactor +
|
||||
20 unit tests. Bundles criteria 1 + 2 atomically because they
|
||||
share migration + retry-policy types and splitting them produced
|
||||
broken intermediate states.
|
||||
- `a3c0130` `feat(settings): notification channel status UI with
|
||||
re-enable + test` — wires `<NotificationStatusCard />` into the
|
||||
notifications settings section.
|
||||
|
||||
## Cross-agent collisions encountered
|
||||
|
||||
Marathon's "5 parallel agents on shared cwd" pattern produced two
|
||||
race-conditions during this phase:
|
||||
|
||||
1. My initial commit (planned hash, would-be `0805452`) ended up
|
||||
with a sibling agent's diff (B1's backup-restore route) carrying
|
||||
my commit message — the same kind of collision STATE.md flagged
|
||||
during phase A. I `git reset HEAD~1` + recommitted via
|
||||
`git commit -o <files>` to bind exact paths.
|
||||
2. Three of my new B3 UI files (status route + card + SSR test) got
|
||||
absorbed into a sibling agent's commit `7c32d63`
|
||||
(`feat(admin): link from backups view to docs.healthlog.dev/...`)
|
||||
during their `git add`. The files are correct on main, just under
|
||||
a misleading subject. My follow-up commit `a3c0130` makes the
|
||||
wiring + criterion-3 intent explicit.
|
||||
|
||||
Recommendation for v1.4.16: spawn each agent in its own git worktree
|
||||
Recommendation for v1.4.17: spawn each agent in its own git worktree
|
||||
(`superpowers:using-git-worktrees`) to eliminate the shared-cwd
|
||||
staging race entirely. STATE.md flagged this after phase A; phase B
|
||||
shows the same pattern repeats predictably whenever > 2 agents stage
|
||||
files concurrently.
|
||||
staging race entirely. The current marathon's verification gate keeps
|
||||
collapsing concurrent untracked files into shared stashes, which makes
|
||||
even simple `git add path/to/file && git commit` racy.
|
||||
|
||||
## Files added / modified (final state on origin/main)
|
||||
|
||||
- `prisma/schema.prisma`, `prisma/migrations/0032_host_metric/migration.sql`
|
||||
- `src/lib/jobs/host-metric-sampler.ts` + `__tests__/host-metric-sampler.test.ts`
|
||||
- `src/lib/jobs/reminder-worker.ts` (sampler queue registration)
|
||||
- `src/app/api/admin/host-metrics/route.ts`
|
||||
- `tests/integration/admin-host-metrics.test.ts`
|
||||
- `src/components/admin/host-metrics-chart.tsx` + `__tests__/host-metrics-chart.test.tsx`
|
||||
- `src/components/admin/system-status-section.tsx` (wiring)
|
||||
- `messages/en.json`, `messages/de.json` (admin.hostMetrics.*)
|
||||
- `tests/integration/setup.ts` (truncate `host_metrics`)
|
||||
|
||||
## Did NOT touch (scope guards)
|
||||
|
||||
- `src/components/admin/backups-section.tsx` and backup endpoints
|
||||
(B1's territory).
|
||||
- `src/lib/withings/`, `src/lib/moodlog/`, integrations-section.tsx
|
||||
(B2's territory).
|
||||
- `.github/workflows/*` (C3's territory).
|
||||
|
||||
## Notes for follow-up work
|
||||
|
||||
- `consecutive_failures` is incremented on hard-reject too (so the
|
||||
status UI can show "5 in a row before we gave up"). Re-enable
|
||||
resets to 0.
|
||||
- The reminder-worker still calls `dispatchNotification()` once per
|
||||
scheduled phase; the cooldown-skip protects against quota burn,
|
||||
but a future v1.4.16 follow-up could surface "X reminders skipped
|
||||
while channel paused" as a Wide Event metric.
|
||||
- Telegram webhook flow (`src/app/api/telegram/webhook/route.ts`)
|
||||
still uses `sendTelegramMessage` directly with the legacy
|
||||
`SendMessageResult` shape — not in scope for B3 (it's the inbound
|
||||
bot reply path, not a dispatch). The `errorDescription` field
|
||||
added in this phase is available there if a future refactor wants
|
||||
to consume it.
|
||||
- `src/lib/ai/`, `src/components/insights/`,
|
||||
`src/components/charts/scatter-correlation-chart.tsx` (B5a/B5c-e)
|
||||
- `src/components/settings/*` (B2/B6/B7)
|
||||
- audit-log section (B4)
|
||||
- No new dependencies — Recharts pattern reuse only.
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Unit-level smoke for the per-type export endpoints
|
||||
* (`/api/export/{measurements,medications,mood}` and
|
||||
* `/api/export/full-backup`).
|
||||
*
|
||||
* Exercises the auth gate, content-type, content-disposition, audit-log
|
||||
* action name, and rate-limit wiring. The full DB round-trip lives in
|
||||
* the integration suite (`tests/integration/export-per-type.test.ts`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
||||
user: { id: "user-1", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkReq(url: string): NextRequest {
|
||||
return new NextRequest(url, { method: "GET" });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/export/measurements", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../measurements/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/measurements"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns text/csv with attachment disposition on success", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../measurements/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/measurements"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
|
||||
expect(res.headers.get("content-disposition")).toMatch(
|
||||
/attachment; filename="healthlog-measurements-/,
|
||||
);
|
||||
expect(res.headers.get("content-disposition")).toMatch(/\.csv"/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("WEIGHT,80,kg");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.export.measurements",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("honours since/until query params via measuredAt range", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../measurements/route");
|
||||
await GET(
|
||||
mkReq(
|
||||
"http://localhost/api/export/measurements?since=2026-04-01&until=2026-05-01",
|
||||
),
|
||||
);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
measuredAt: expect.objectContaining({
|
||||
gte: new Date("2026-04-01"),
|
||||
lte: new Date("2026-05-01"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/medications", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../medications/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/medications"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("appends intake-history section when intake=true", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
name: "Aspirin",
|
||||
dose: "100mg",
|
||||
active: true,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
label: "Morning",
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
medication: { name: "Aspirin" },
|
||||
scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-05-01T08:05:00.000Z"),
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../medications/route");
|
||||
const res = await GET(
|
||||
mkReq("http://localhost/api/export/medications?intake=true"),
|
||||
);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Aspirin");
|
||||
// The intake-history section is delimited so a downstream tool can split.
|
||||
expect(body).toContain("# Intake history");
|
||||
expect(body).toContain("scheduledFor");
|
||||
});
|
||||
|
||||
it("omits intake-history section when intake=false", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ name: "X", dose: "1", active: true, schedules: [] },
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../medications/route");
|
||||
const res = await GET(
|
||||
mkReq("http://localhost/api/export/medications?intake=false"),
|
||||
);
|
||||
const body = await res.text();
|
||||
expect(body).not.toContain("# Intake history");
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/mood", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../mood/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/mood"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns text/csv with mood rows on success", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "good",
|
||||
score: 4,
|
||||
tags: null,
|
||||
source: "WEB",
|
||||
moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../mood/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/mood"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("good");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.export.mood",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/full-backup", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns a JSON backup that matches the canonical schema", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/application\/json/);
|
||||
expect(res.headers.get("content-disposition")).toMatch(
|
||||
/attachment; filename="healthlog-backup-/,
|
||||
);
|
||||
expect(res.headers.get("content-disposition")).toMatch(/\.json"/);
|
||||
const json = await res.json();
|
||||
expect(json).toMatchObject({
|
||||
schemaVersion: "1",
|
||||
userId: "user-1",
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
});
|
||||
expect(typeof json.exportedAt).toBe("string");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.export.full-backup",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* GET /api/export/full-backup
|
||||
*
|
||||
* v1.4.16 phase B7. Single-file user-scoped JSON dump that matches the
|
||||
* canonical `backupPayloadSchema` (see `src/lib/validations/backup.ts`)
|
||||
* — same shape the pg-boss `data-backup` worker writes weekly, so a
|
||||
* user can hand this file to an admin and `POST /api/admin/backups/upload`
|
||||
* accepts it without further conversion.
|
||||
*
|
||||
* Response is `application/json` with an attachment filename ending in
|
||||
* `.json` so the browser writes a `.json` file even though the route
|
||||
* segment doesn't carry the extension.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.full-backup` with the row counts.
|
||||
*/
|
||||
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, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.full-backup" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
// Explicit `select` on the schedule columns — see the matching
|
||||
// comment in `medications/route.ts` for the rationale (the
|
||||
// integration testbed has an unmigrated `days_of_week` column on
|
||||
// the schema branch we ship alongside).
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
|
||||
// `parseBackupPayload()` validator round-trips both blobs. Keep these
|
||||
// two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
|
||||
// canonical reference.
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
measurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
notes: m.notes,
|
||||
})),
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
medication: e.medication.name,
|
||||
scheduledFor: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
counts: {
|
||||
measurements: measurements.length,
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
moodEntries: moodEntries.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: measurements.length,
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeEvents.length,
|
||||
export_mood_count: moodEntries.length,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
|
||||
// the file is a self-contained backup — admin upload + restore expect
|
||||
// the raw payload, not `{ data: { ... } }`.
|
||||
return new NextResponse(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.json"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* GET /api/export/measurements
|
||||
*
|
||||
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
||||
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
|
||||
* date strings parsed via the standard Date constructor — invalid
|
||||
* values are silently dropped from the where-clause).
|
||||
*
|
||||
* Response is `text/csv` with an attachment filename ending in `.csv`,
|
||||
* so the browser writes a `.csv` file even though the route segment
|
||||
* itself doesn't carry the extension (Next.js + vitest can't resolve
|
||||
* dotted route segments cleanly — see commit log for context).
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) so the user can't
|
||||
* sidestep the global cap by hitting per-type endpoints in parallel.
|
||||
* Audit: `user.export.measurements` with the resolved filter.
|
||||
*/
|
||||
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, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.measurements" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const { since, until } = parseRange(request.url);
|
||||
const where = buildWhere(user.id, { since, until });
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
|
||||
const csv = toCSV(formatMeasurementsForExport(measurements));
|
||||
|
||||
await auditLog("user.export.measurements", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
count: measurements.length,
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_count: measurements.length,
|
||||
export_since: since?.toISOString() ?? null,
|
||||
export_until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return csvResponse(csv, `healthlog-measurements-${user.id}`);
|
||||
});
|
||||
|
||||
function parseRange(url: string): {
|
||||
since: Date | undefined;
|
||||
until: Date | undefined;
|
||||
} {
|
||||
const params = new URL(url).searchParams;
|
||||
const sinceRaw = params.get("since");
|
||||
const untilRaw = params.get("until");
|
||||
const since = sinceRaw ? safeDate(sinceRaw) : undefined;
|
||||
const until = untilRaw ? safeDate(untilRaw) : undefined;
|
||||
return { since, until };
|
||||
}
|
||||
|
||||
function safeDate(value: string): Date | undefined {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function buildWhere(
|
||||
userId: string,
|
||||
range: { since?: Date; until?: Date },
|
||||
): { userId: string; measuredAt?: { gte?: Date; lte?: Date } } {
|
||||
const where: { userId: string; measuredAt?: { gte?: Date; lte?: Date } } = {
|
||||
userId,
|
||||
};
|
||||
if (range.since || range.until) {
|
||||
where.measuredAt = {};
|
||||
if (range.since) where.measuredAt.gte = range.since;
|
||||
if (range.until) where.measuredAt.lte = range.until;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function csvResponse(body: string, prefix: string): NextResponse {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${prefix}-${stamp}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* GET /api/export/medications
|
||||
*
|
||||
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
||||
* `/settings/export` UI. Always includes the medication list; appends
|
||||
* a `# Intake history` block when `?intake=true` (the default — the
|
||||
* UI toggle is checked by default so a stripped-down export is opt-in).
|
||||
*
|
||||
* Response is `text/csv` with an attachment filename ending in `.csv`.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) so the user can't
|
||||
* sidestep the global cap by hitting per-type endpoints in parallel.
|
||||
* Audit: `user.export.medications` with the resolved filter + intake flag.
|
||||
*/
|
||||
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, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
toCSV,
|
||||
formatMedicationsForExport,
|
||||
formatIntakeEventsForExport,
|
||||
} from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.medications" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const includeIntake = params.get("intake") !== "false";
|
||||
const sinceRaw = params.get("since");
|
||||
const untilRaw = params.get("until");
|
||||
const since = sinceRaw ? safeDate(sinceRaw) : undefined;
|
||||
const until = untilRaw ? safeDate(untilRaw) : undefined;
|
||||
|
||||
// Explicit `select` on the schedule columns we actually format. The
|
||||
// integration testbed has an unmigrated `medication_schedules.days_of_week`
|
||||
// column on this branch (sibling work added the field to the schema
|
||||
// without a matching migration); a wildcard `include: { schedules: true }`
|
||||
// would 500 on the missing column. Pin the columns instead — the export
|
||||
// shape doesn't need every future field anyway.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const sections: string[] = [
|
||||
"# Medications",
|
||||
toCSV(formatMedicationsForExport(medications)),
|
||||
];
|
||||
let intakeCount = 0;
|
||||
|
||||
if (includeIntake) {
|
||||
const where: {
|
||||
userId: string;
|
||||
scheduledFor?: { gte?: Date; lte?: Date };
|
||||
} = {
|
||||
userId: user.id,
|
||||
};
|
||||
if (since || until) {
|
||||
where.scheduledFor = {};
|
||||
if (since) where.scheduledFor.gte = since;
|
||||
if (until) where.scheduledFor.lte = until;
|
||||
}
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where,
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
intakeCount = events.length;
|
||||
sections.push("# Intake history");
|
||||
sections.push(toCSV(formatIntakeEventsForExport(events)));
|
||||
}
|
||||
|
||||
const body = sections.join("\n\n");
|
||||
|
||||
await auditLog("user.export.medications", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationCount: medications.length,
|
||||
intakeCount,
|
||||
includeIntake,
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeCount,
|
||||
export_include_intake: includeIntake,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-medications-${user.id}-${stamp}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function safeDate(value: string): Date | undefined {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET /api/export/mood
|
||||
*
|
||||
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
||||
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
|
||||
* date strings parsed via the standard Date constructor — invalid
|
||||
* values are silently dropped from the where-clause).
|
||||
*
|
||||
* Response is `text/csv` with an attachment filename ending in `.csv`.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.mood` with the resolved filter.
|
||||
*/
|
||||
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, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.export.mood" } });
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const since = params.get("since")
|
||||
? safeDate(params.get("since")!)
|
||||
: undefined;
|
||||
const until = params.get("until")
|
||||
? safeDate(params.get("until")!)
|
||||
: undefined;
|
||||
|
||||
const where: {
|
||||
userId: string;
|
||||
moodLoggedAt?: { gte?: Date; lte?: Date };
|
||||
} = {
|
||||
userId: user.id,
|
||||
};
|
||||
if (since || until) {
|
||||
where.moodLoggedAt = {};
|
||||
if (since) where.moodLoggedAt.gte = since;
|
||||
if (until) where.moodLoggedAt.lte = until;
|
||||
}
|
||||
|
||||
const entries = await prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
|
||||
const csv = toCSV(formatMoodEntriesForExport(entries));
|
||||
|
||||
await auditLog("user.export.mood", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
count: entries.length,
|
||||
since: since?.toISOString() ?? null,
|
||||
until: until?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_count: entries.length,
|
||||
},
|
||||
});
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-mood-${user.id}-${stamp}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function safeDate(value: string): Date | undefined {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Integration regression guard for the per-type export endpoints
|
||||
* (v1.4.16 phase B7):
|
||||
*
|
||||
* GET /api/export/measurements.csv
|
||||
* GET /api/export/medications.csv
|
||||
* GET /api/export/mood.csv
|
||||
* GET /api/export/full-backup.json
|
||||
*
|
||||
* Each endpoint:
|
||||
* 1. Is gated by `requireAuth()`.
|
||||
* 2. Returns the right content-type + attachment disposition.
|
||||
* 3. Writes a `user.export.<kind>` audit-log entry.
|
||||
* 4. Round-trips real DB rows for the authenticated user only
|
||||
* (a different user's data must NOT leak).
|
||||
*
|
||||
* The full-backup endpoint additionally has its body validated against
|
||||
* the canonical `backupPayloadSchema` so the file the user downloads
|
||||
* is the same shape `POST /api/admin/backups/upload` accepts.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seedUserSession(username = "export-user") {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("GET /api/export/measurements.csv", () => {
|
||||
it("returns 401 without a session", async () => {
|
||||
const { GET } = await import("@/app/api/export/measurements.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/measurements.csv", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 + CSV body for the authed user only (no cross-tenant leak)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const me = await seedUserSession("alice");
|
||||
const other = await prisma.user.create({
|
||||
data: {
|
||||
username: "bob",
|
||||
email: "bob@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: me.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
},
|
||||
{
|
||||
userId: other.id,
|
||||
type: "WEIGHT",
|
||||
value: 999,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/export/measurements.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/measurements.csv", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
|
||||
expect(res.headers.get("content-disposition")).toMatch(
|
||||
/attachment; filename=.*healthlog-measurements/,
|
||||
);
|
||||
|
||||
const body = await res.text();
|
||||
expect(body).toContain("WEIGHT,80,kg");
|
||||
// Bob's row must never leak into Alice's export.
|
||||
expect(body).not.toContain("999");
|
||||
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.measurements" },
|
||||
});
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/medications.csv", () => {
|
||||
it("returns 401 without a session", async () => {
|
||||
const { GET } = await import("@/app/api/export/medications.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/medications.csv", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("includes intake history when intake=true (default)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const me = await seedUserSession("med-user");
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
name: "Aspirin",
|
||||
dose: "100mg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-05-01T08:05:00.000Z"),
|
||||
skipped: false,
|
||||
// IntakeSource enum values: WEB, API, REMINDER, IMPORT
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/export/medications.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/medications.csv", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Aspirin");
|
||||
expect(body).toContain("# Intake history");
|
||||
expect(body).toContain("scheduledFor");
|
||||
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.medications" },
|
||||
});
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
|
||||
it("omits intake history when intake=false", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const me = await seedUserSession("med-user-2");
|
||||
await prisma.medication.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
name: "Ibuprofen",
|
||||
dose: "200mg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/export/medications.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/medications.csv?intake=false", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Ibuprofen");
|
||||
expect(body).not.toContain("# Intake history");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/mood.csv", () => {
|
||||
it("returns 401 without a session", async () => {
|
||||
const { GET } = await import("@/app/api/export/mood.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/mood.csv", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the user's mood entries as CSV", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const me = await seedUserSession("mood-user");
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
date: "2026-05-01",
|
||||
mood: "good",
|
||||
score: 4,
|
||||
// MoodEntry.source is a plain string column; "WEB" matches the
|
||||
// values the rest of the suite uses.
|
||||
source: "WEB",
|
||||
moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/export/mood.csv/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/mood.csv", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/text\/csv/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("good");
|
||||
expect(body).toContain("2026-05-01");
|
||||
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.mood" },
|
||||
});
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/full-backup.json", () => {
|
||||
it("returns 401 without a session", async () => {
|
||||
const { GET } = await import("@/app/api/export/full-backup.json/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/full-backup.json", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns a JSON blob that round-trips through `parseBackupPayload`", async () => {
|
||||
const { parseBackupPayload } = await import("@/lib/validations/backup");
|
||||
const prisma = getPrismaClient();
|
||||
const me = await seedUserSession("backup-user");
|
||||
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
date: "2026-05-01",
|
||||
mood: "great",
|
||||
score: 5,
|
||||
source: "WEB",
|
||||
moodLoggedAt: new Date("2026-05-01T20:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/export/full-backup.json/route");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/export/full-backup.json", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toMatch(/application\/json/);
|
||||
expect(res.headers.get("content-disposition")).toMatch(
|
||||
/attachment; filename=.*healthlog-backup/,
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
const parsed = parseBackupPayload(text);
|
||||
expect(parsed.userId).toBe(me.id);
|
||||
expect(parsed.measurements.length).toBe(1);
|
||||
expect(parsed.moodEntries.length).toBe(1);
|
||||
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.full-backup" },
|
||||
});
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user