990f5582b8
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.
230 lines
7.3 KiB
TypeScript
230 lines
7.3 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 { NextRequest } from 'next/server';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { POST as importPost } from '@/app/api/me/import/route';
|
|
|
|
function jsonReq(body: unknown): NextRequest {
|
|
return new NextRequest('http://x/api/me/import', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
} as ConstructorParameters<typeof NextRequest>[1]);
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
const sampleExport = (overrides?: Partial<{ exercises: unknown[]; workouts: unknown[] }>) => ({
|
|
schema: 'proof-of-work-export@1',
|
|
exercises: overrides?.exercises ?? [
|
|
{
|
|
id: 'cE1',
|
|
name: 'Imported Bench',
|
|
type: 'barbell',
|
|
muscleGroups: '["chest"]',
|
|
inputFields: '["sets","reps","weight"]',
|
|
defaultWeightUnit: null,
|
|
isCustom: false,
|
|
},
|
|
{
|
|
id: 'cE2',
|
|
name: 'Imported Squat',
|
|
type: 'barbell',
|
|
muscleGroups: '["legs"]',
|
|
inputFields: '["sets","reps","weight"]',
|
|
defaultWeightUnit: null,
|
|
isCustom: false,
|
|
},
|
|
],
|
|
workouts: overrides?.workouts ?? [
|
|
{
|
|
date: '2026-04-01T12:00:00.000Z',
|
|
name: 'Day 1',
|
|
setLogs: [
|
|
{ exerciseId: 'cE1', setNumber: 1, reps: 5, weight: 225 },
|
|
{ exerciseId: 'cE1', setNumber: 2, reps: 5, weight: 245 },
|
|
],
|
|
},
|
|
{
|
|
date: '2026-04-03T12:00:00.000Z',
|
|
name: 'Day 2',
|
|
setLogs: [
|
|
{ exerciseId: 'cE2', setNumber: 1, reps: 5, weight: 315 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
describe('POST /api/me/import', () => {
|
|
it('returns 401 unauthenticated', async () => {
|
|
getCurrentUserMock.mockResolvedValue(null);
|
|
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects unknown schema versions', async () => {
|
|
const u = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(u);
|
|
const res = await importPost(
|
|
jsonReq({
|
|
payload: { schema: 'something-else@99', exercises: [], workouts: [] },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('merge mode imports exercises and workouts attributed to the actor', async () => {
|
|
const u = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(u);
|
|
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.exercisesCreated).toBe(2);
|
|
expect(body.workoutsCreated).toBe(2);
|
|
expect(body.setsCreated).toBe(3);
|
|
|
|
const exercises = await prisma.exercise.findMany({
|
|
where: { userId: u.id },
|
|
});
|
|
expect(exercises.map((e) => e.name).sort()).toEqual([
|
|
'Imported Bench',
|
|
'Imported Squat',
|
|
]);
|
|
const workouts = await prisma.workout.findMany({
|
|
where: { userId: u.id },
|
|
include: { setLogs: true },
|
|
});
|
|
expect(workouts).toHaveLength(2);
|
|
const totalSets = workouts.reduce((n, w) => n + w.setLogs.length, 0);
|
|
expect(totalSets).toBe(3);
|
|
});
|
|
|
|
it('merge mode skips exercises whose name already exists for the user', async () => {
|
|
const u = await makeUser({ email: 'a@x' });
|
|
await prisma.exercise.create({
|
|
data: {
|
|
userId: u.id,
|
|
name: 'Imported Bench',
|
|
type: 'barbell',
|
|
muscleGroups: '["chest"]',
|
|
isCustom: true, // user's own custom version
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(u);
|
|
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
|
const body = await res.json();
|
|
expect(body.exercisesCreated).toBe(1); // only Squat
|
|
expect(body.exercisesSkipped).toBe(1); // Bench already existed
|
|
|
|
// The user's custom row was NOT overwritten.
|
|
const bench = await prisma.exercise.findUnique({
|
|
where: { userId_name: { userId: u.id, name: 'Imported Bench' } },
|
|
});
|
|
expect(bench?.isCustom).toBe(true);
|
|
|
|
// Workouts were still created and bound to the user's existing
|
|
// (custom) Bench, not the imported one.
|
|
const sets = await prisma.setLog.findMany({
|
|
where: { exerciseId: bench!.id },
|
|
});
|
|
expect(sets.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('replace mode requires explicit confirmation', async () => {
|
|
const u = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(u);
|
|
const res = await importPost(
|
|
jsonReq({ payload: sampleExport(), mode: 'replace' }),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/REPLACE/);
|
|
});
|
|
|
|
it('replace mode wipes existing user-owned data first', async () => {
|
|
const u = await makeUser({ email: 'a@x' });
|
|
const existing = await prisma.exercise.create({
|
|
data: {
|
|
userId: u.id,
|
|
name: 'Old Custom',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
await prisma.workout.create({
|
|
data: {
|
|
userId: u.id,
|
|
date: new Date('2026-01-01'),
|
|
setLogs: { create: [{ exerciseId: existing.id, setNumber: 1, reps: 1 }] },
|
|
},
|
|
});
|
|
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(1);
|
|
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
|
|
|
|
getCurrentUserMock.mockResolvedValue(u);
|
|
const res = await importPost(
|
|
jsonReq({
|
|
payload: sampleExport(),
|
|
mode: 'replace',
|
|
confirm: 'REPLACE',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.mode).toBe('replace');
|
|
|
|
// Old custom is gone; new imported data is in place.
|
|
expect(
|
|
await prisma.exercise.findUnique({
|
|
where: { userId_name: { userId: u.id, name: 'Old Custom' } },
|
|
}),
|
|
).toBeNull();
|
|
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(2);
|
|
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(2);
|
|
});
|
|
|
|
it('keeps imports scoped to the actor, never to other users', async () => {
|
|
const me = await makeUser({ email: 'me@x' });
|
|
const other = await makeUser({ email: 'other@x' });
|
|
await prisma.exercise.create({
|
|
data: {
|
|
userId: other.id,
|
|
name: 'Other User Exercise',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(me);
|
|
await importPost(jsonReq({ payload: sampleExport() }));
|
|
|
|
// The other user's data is completely untouched.
|
|
expect(await prisma.exercise.count({ where: { userId: other.id } })).toBe(1);
|
|
expect(await prisma.workout.count({ where: { userId: other.id } })).toBe(0);
|
|
expect(
|
|
(await prisma.exercise.findFirst({ where: { userId: other.id } }))?.name,
|
|
).toBe('Other User Exercise');
|
|
});
|
|
});
|