Test suite (Vitest) + GitHub Actions CI

Test suite (proof-of-work/tests/)
- vitest 4 + @vitest/coverage-v8 added as devDeps. New scripts: test,
  test:watch, test:coverage.
- vitest.config.ts: single-fork pool so DB-backed tests don't trample
  each other on temp file paths. `@/` alias mirrors tsconfig.
- tests/helpers/db.ts: setupTestDb() spins up a fresh schema-only
  SQLite file per test suite via `prisma db push --skip-generate`,
  returns a scoped PrismaClient + cleanup that removes WAL/SHM
  sidecars too.
- tests/rateLimit.test.ts: under-limit / over-limit / per-key
  isolation / window-slides-and-allows-again. Plus tests for
  clientIpFromHeaders header preference order.
- tests/auth-pure.test.ts: hashPassword roundtrips, salt-randomness
  (same input, different hash), bcrypt format ($2 prefix).
- tests/library.test.ts: actually runs the runtime
  ensureExerciseLibrary.cjs against a temp DB with two users — verifies
  the full library lands for every user, idempotent across two runs,
  and a user's own custom exercise with a colliding name is NOT
  overwritten on subsequent ensure passes. This is the highest-stakes
  test in the suite (covers the exact code path that runs on every
  container boot).

12 tests, ~1.0s total.

GitHub Actions CI (.github/workflows/ci.yml)
- Two jobs running in parallel on push + PR to master/main:
  - `app`: cd proof-of-work && npm ci && prisma validate && prisma
    generate && tsc --noEmit && npm test
  - `startos`: cd start9/0.4 && npm ci && npm run check (the
    StartOS package's existing tsc --noEmit script)
- Both jobs use Node 20 with npm cache keyed off the package-lock.
This commit is contained in:
Keysat
2026-05-09 10:22:22 -05:00
parent d51400c2a9
commit 65f4b7a7c7
8 changed files with 1705 additions and 25 deletions
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
app:
name: proof-of-work (Next.js app)
runs-on: ubuntu-latest
defaults:
run:
working-directory: proof-of-work
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: proof-of-work/package-lock.json
- run: npm ci
- name: Prisma validate
run: npx prisma validate
- name: Prisma generate
run: npx prisma generate
- name: Type-check
run: npx tsc --noEmit
- name: Tests
run: npm test
startos:
name: start9/0.4 (StartOS package code)
runs-on: ubuntu-latest
defaults:
run:
working-directory: start9/0.4
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: start9/0.4/package-lock.json
- run: npm ci
- name: Type-check StartOS package source
run: npm run check
+1364 -13
View File
File diff suppressed because it is too large Load Diff
+17 -12
View File
@@ -11,30 +11,35 @@
"db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio",
"sync-library": "node scripts/sync-library.cjs"
"sync-library": "node scripts/sync-library.cjs",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@prisma/client": "^5.0.0",
"autoprefixer": "^10.4.16",
"bcryptjs": "^2.4.3",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
"next": "^14.0.0",
"next-themes": "^0.2.1",
"postcss": "^8.4.31",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@prisma/client": "^5.0.0",
"bcryptjs": "^2.4.3",
"tailwindcss": "^3.3.0",
"postcss": "^8.4.31",
"autoprefixer": "^10.4.16",
"clsx": "^2.0.0",
"tailwind-merge": "^2.2.0",
"lucide-react": "^0.294.0",
"next-themes": "^0.2.1",
"tailwindcss": "^3.3.0",
"zod": "^3.22.4"
},
"devDependencies": {
"prisma": "^5.0.0",
"@types/bcryptjs": "^2.4.2",
"typescript": "^5.2.2",
"@types/node": "^20.5.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"tsx": "^4.7.0"
"@vitest/coverage-v8": "^4.1.5",
"prisma": "^5.0.0",
"tsx": "^4.7.0",
"typescript": "^5.2.2",
"vitest": "^4.1.5"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '@/lib/auth';
// Pure-function bits of lib/auth.ts (no Prisma, no cookies).
describe('hashPassword / verifyPassword', () => {
it('verifies a correct password', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(await verifyPassword('correct horse battery staple', hash)).toBe(true);
});
it('rejects an incorrect password', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(await verifyPassword('wrong horse', hash)).toBe(false);
});
it('produces different hashes for the same password (salt-randomness)', async () => {
const a = await hashPassword('same input');
const b = await hashPassword('same input');
expect(a).not.toBe(b);
expect(await verifyPassword('same input', a)).toBe(true);
expect(await verifyPassword('same input', b)).toBe(true);
});
it('produces a bcrypt-format hash starting with $2', async () => {
const hash = await hashPassword('whatever');
expect(hash.startsWith('$2')).toBe(true);
});
});
+70
View File
@@ -0,0 +1,70 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { PrismaClient } from '@prisma/client';
/**
* Per-test-suite SQLite DB. Creates a fresh schema-only DB at a temp
* path, returns a PrismaClient pointed at it, and exposes a teardown.
*
* Each test file calls `setupTestDb()` in beforeAll and the returned
* `cleanup()` in afterAll. State is NOT cleared between individual
* tests inside a file — write tests so they either tear down their
* own rows or use unique values.
*/
let counter = 0;
export interface TestDb {
prisma: PrismaClient;
dbPath: string;
cleanup: () => Promise<void>;
}
export async function setupTestDb(): Promise<TestDb> {
const id = `${process.pid}-${Date.now()}-${++counter}`;
const dbPath = path.resolve('/tmp', `pow-test-${id}.db`);
// Apply current Prisma schema to the temp DB. `prisma db push` is the
// right tool here (skips migration history bookkeeping).
execFileSync(
'npx',
[
'prisma',
'db',
'push',
'--skip-generate',
'--accept-data-loss',
],
{
env: { ...process.env, DATABASE_URL: `file:${dbPath}` },
stdio: 'pipe',
},
);
const prisma = new PrismaClient({
datasources: { db: { url: `file:${dbPath}` } },
log: ['error'],
});
return {
prisma,
dbPath,
cleanup: async () => {
await prisma.$disconnect();
try {
fs.unlinkSync(dbPath);
} catch {
/* already gone */
}
// Clean up SQLite sidecar files too.
for (const ext of ['-journal', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + ext);
} catch {
/* */
}
}
},
};
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { setupTestDb, type TestDb } from './helpers/db';
let db: TestDb;
let library: { name: string }[];
beforeAll(async () => {
db = await setupTestDb();
// The library JSON shipped with the app — same one consumed in prod.
library = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '..', 'prisma', 'exercises.seed.json'),
'utf8',
),
);
});
afterAll(async () => {
await db?.cleanup();
});
describe('ensureExerciseLibrary.cjs (runtime entrypoint script)', () => {
it('inserts the full library for every user, idempotently', async () => {
// Two users, no exercises yet.
const alice = await db.prisma.user.create({
data: { email: 'alice@test', passwordHash: 'x', isAdmin: true },
});
const bob = await db.prisma.user.create({
data: { email: 'bob@test', passwordHash: 'x' },
});
const scriptPath = path.resolve(
__dirname,
'..',
'prisma',
'ensureExerciseLibrary.cjs',
);
const jsonPath = path.resolve(
__dirname,
'..',
'prisma',
'exercises.seed.json',
);
const run = () =>
execFileSync(
'node',
[scriptPath, '--db', db.dbPath, '--json', jsonPath],
{ stdio: 'pipe' },
);
run();
const aliceCount = await db.prisma.exercise.count({
where: { userId: alice.id },
});
const bobCount = await db.prisma.exercise.count({
where: { userId: bob.id },
});
expect(aliceCount).toBe(library.length);
expect(bobCount).toBe(library.length);
// Idempotency — second run must not duplicate or change counts.
run();
expect(
await db.prisma.exercise.count({ where: { userId: alice.id } }),
).toBe(library.length);
expect(
await db.prisma.exercise.count({ where: { userId: bob.id } }),
).toBe(library.length);
// A user's own custom exercise with the same name as a library entry
// must NOT be overwritten by a subsequent ensure pass.
const targetName = library[0].name;
await db.prisma.exercise.deleteMany({
where: { userId: bob.id, name: targetName },
});
await db.prisma.exercise.create({
data: {
userId: bob.id,
name: targetName,
muscleGroups: '[]',
type: 'custom-by-bob',
isCustom: true,
},
});
run();
const bobOwn = await db.prisma.exercise.findUnique({
where: { userId_name: { userId: bob.id, name: targetName } },
});
expect(bobOwn?.isCustom).toBe(true);
expect(bobOwn?.type).toBe('custom-by-bob');
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
describe('rateLimit', () => {
it('allows requests under the limit', () => {
const key = `test-allow-${Math.random()}`;
for (let i = 0; i < 5; i++) {
const r = rateLimit(key, { limit: 5, windowMs: 60_000 });
expect(r.ok).toBe(true);
}
});
it('blocks requests over the limit and reports retryAfterSec > 0', () => {
const key = `test-block-${Math.random()}`;
for (let i = 0; i < 3; i++) {
rateLimit(key, { limit: 3, windowMs: 60_000 });
}
const r = rateLimit(key, { limit: 3, windowMs: 60_000 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.retryAfterSec).toBeGreaterThan(0);
});
it('isolates buckets by key', () => {
const a = `iso-a-${Math.random()}`;
const b = `iso-b-${Math.random()}`;
for (let i = 0; i < 2; i++) {
rateLimit(a, { limit: 2, windowMs: 60_000 });
}
expect(rateLimit(a, { limit: 2, windowMs: 60_000 }).ok).toBe(false);
expect(rateLimit(b, { limit: 2, windowMs: 60_000 }).ok).toBe(true);
});
it('lets requests in again after the window slides', async () => {
const key = `slide-${Math.random()}`;
rateLimit(key, { limit: 1, windowMs: 50 });
expect(rateLimit(key, { limit: 1, windowMs: 50 }).ok).toBe(false);
await new Promise((r) => setTimeout(r, 60));
expect(rateLimit(key, { limit: 1, windowMs: 50 }).ok).toBe(true);
});
});
describe('clientIpFromHeaders', () => {
it('uses the leftmost x-forwarded-for entry', () => {
const h = new Headers({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
expect(clientIpFromHeaders(h)).toBe('1.2.3.4');
});
it('falls back to x-real-ip', () => {
const h = new Headers({ 'x-real-ip': '9.9.9.9' });
expect(clientIpFromHeaders(h)).toBe('9.9.9.9');
});
it('returns "unknown" when no headers present', () => {
expect(clientIpFromHeaders(new Headers())).toBe('unknown');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
globals: false,
environment: 'node',
// Tests that touch the DB pull a fresh schema into a temp file in a
// beforeAll hook (see tests/helpers/db.ts). Single fork so distinct
// test files don't trample each other on the same `app.db` path
// resolution.
pool: 'forks',
forks: { singleFork: true },
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
});