Files
proof-of-work/proof-of-work/tests/routes-import.test.ts
T
Keysat 990f5582b8 Typed Prisma queries, bcrypt native, CSP nonces, /api/me/import, more tests
Typed Prisma queries
- where: any in app/api/workouts/route.ts (GET + POST) and
  lib/db/workouts.ts replaced with Prisma.WorkoutWhereInput +
  Prisma.WorkoutCreateInput + Prisma.DateTimeFilter. Catches typos
  at compile time and surfaces query shape directly in tooltips.

Workout import endpoint tests (tests/routes-import.test.ts)
- 7 tests covering /api/workouts/import/save: 401 unauthenticated,
  empty workouts rejected, case-insensitive name matching against
  existing exercises, new-exercise creation with isCustom=true and
  type='other' default, explicit existingExerciseId honored over
  name lookup, multiple workouts per call, sequential setNumber
  per exercise per workout.

bcryptjs -> bcrypt (native)
- Roughly 10x faster than the pure-JS implementation under load —
  login latency drops from ~250ms to ~25ms. Hash format is fully
  cross-compatible with bcryptjs ($2a$ / $2b$ both verify), so
  existing user passwords keep working without migration.
- Dockerfile builder stage adds python3 + make + g++ as a safety net
  for native node-gyp compilation on alpine when prebuilt binaries
  aren't available.
- Runner stage explicitly COPYs node_modules/bcrypt so the .node
  binding is unambiguously present even if Next.js standalone
  tracing somehow misses it.
- StartOS package's changeAdminCredentials.ts keeps bcryptjs (it's
  bundled by ncc into a single JS file and runs only on the rare
  admin action; native bcrypt would require shipping the .node
  binding through ncc which it doesn't handle gracefully).

CSP nonces (middleware.ts + next.config.js)
- Per-request nonce generated in middleware. Forwarded to Next via
  the x-nonce request header, which Next 13.4+ automatically stamps
  onto its inline bootstrap scripts. CSP response header includes
  `'nonce-${nonce}' 'strict-dynamic'`, dropping the previous
  `'unsafe-inline'` from script-src.
- Static CSP removed from next.config.js (middleware-set headers
  override static ones, so keeping both was redundant).
- Middleware matcher widened to all paths except static assets so
  the CSP applies to every page response. Existing /main + /api
  auth gating preserved.
- style-src keeps 'unsafe-inline' — Next/Tailwind still inject
  critical inline <style>; tightening that requires hash-based
  style-src or per-style nonce stamping (Next doesn't auto-do
  either). Worth a follow-up if you want the cleanest possible CSP.

/api/me/import (mirror of /api/me/export)
- Accepts the same JSON shape /api/me/export emits (schema string
  validated: only `proof-of-work-export@1` accepted today).
- mode: 'merge' (default) — adds imported rows; existing exercises
  with matching names are NOT overwritten (the user's custom version
  wins). All workout sets with a known exercise get rebound to the
  user's actual exercise id via name lookup.
- mode: 'replace' — wipes the user's exercises/workouts/sets first,
  then imports. Requires `confirm: "REPLACE"` in the body.
- Always scoped to the actor — never touches other users' data.
- Profile/admin flag/sessions/InstanceSettings deliberately not
  imported (account identity stays put).
- 7 tests cover: 401, schema rejection, merge create+skip, replace
  confirmation gate, replace wipes-then-imports, isolation across
  users.
- ExportMyData component grew Import (merge) + Import (replace)
  buttons with native browser confirm() before the destructive
  replace.

Test suite now 81 tests across 9 files in ~2.6s.
2026-05-09 11:05:03 -05:00

231 lines
6.2 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { prisma } from '@/lib/prisma';
import { POST as saveImport } from '@/app/api/workouts/import/save/route';
function jsonReq(body: unknown): Request {
return new Request('http://x/api/workouts/import/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
async function makeUser(opts: { email: string }) {
return prisma.user.create({
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
});
}
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.exercise.deleteMany();
await prisma.workout.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
describe('POST /api/workouts/import/save', () => {
it('returns 401 when unauthenticated', async () => {
getCurrentUserMock.mockResolvedValue(null);
const res = await saveImport(
jsonReq({ workouts: [{ date: '2026-01-01', exercises: [] }] }),
);
expect(res.status).toBe(401);
});
it('returns 400 on empty workouts array (Zod min(1))', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(jsonReq({ workouts: [] }));
expect(res.status).toBe(400);
});
it('matches existing exercise by case-insensitive name', async () => {
const u = await makeUser({ email: 'a@x' });
const bench = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'bench press', // lowercased — should still match
sets: [{ reps: 5, weight: 225, weightUnit: 'lbs' }],
},
],
},
],
}),
);
expect(res.status).toBe(200);
// Should NOT create a duplicate exercise.
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
const sets = await prisma.setLog.findMany({
where: { exerciseId: bench.id },
});
expect(sets).toHaveLength(1);
expect(sets[0].weight).toBe(225);
});
it('creates new exercise (isCustom=true, type defaults to "other") when no match', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'Brand New Exercise',
sets: [{ reps: 10 }],
},
],
},
],
}),
);
expect(res.status).toBe(200);
const ex = await prisma.exercise.findUnique({
where: {
userId_name: { userId: u.id, name: 'Brand New Exercise' },
},
});
expect(ex).toBeTruthy();
expect(ex?.isCustom).toBe(true);
expect(ex?.type).toBe('other');
});
it('honors explicit existingExerciseId over name lookup', async () => {
const u = await makeUser({ email: 'a@x' });
const a = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Squat',
type: 'barbell',
muscleGroups: '[]',
},
});
const b = await prisma.exercise.create({
data: {
userId: u.id,
name: 'Different Squat Variation',
type: 'barbell',
muscleGroups: '[]',
},
});
getCurrentUserMock.mockResolvedValue(u);
// Even though the name matches `a`, existingExerciseId says use `b`.
await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'Squat',
existingExerciseId: b.id,
sets: [{ reps: 5, weight: 315 }],
},
],
},
],
}),
);
const sets = await prisma.setLog.findMany({
where: { exerciseId: b.id },
});
expect(sets).toHaveLength(1);
expect(
await prisma.setLog.count({ where: { exerciseId: a.id } }),
).toBe(0);
});
it('creates multiple workouts in one call and returns the ids', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-01',
exercises: [{ name: 'Squat', sets: [{ reps: 5 }] }],
},
{
date: '2026-01-03',
exercises: [{ name: 'Bench', sets: [{ reps: 5 }] }],
},
{
date: '2026-01-05',
exercises: [{ name: 'Deadlift', sets: [{ reps: 5 }] }],
},
],
}),
);
const body = await res.json();
expect(body.count).toBe(3);
expect(body.created).toHaveLength(3);
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(3);
});
it('numbers sets sequentially per exercise per workout', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
await saveImport(
jsonReq({
workouts: [
{
date: '2026-01-15',
exercises: [
{
name: 'Squat',
sets: [
{ reps: 5, weight: 135 },
{ reps: 5, weight: 185 },
{ reps: 5, weight: 225 },
],
},
],
},
],
}),
);
const sets = await prisma.setLog.findMany({
orderBy: { setNumber: 'asc' },
});
expect(sets.map((s) => s.setNumber)).toEqual([1, 2, 3]);
expect(sets.map((s) => s.weight)).toEqual([135, 185, 225]);
});
});