Skip to content

Commit 77c79e2

Browse files
Merge pull request #12 from CodeForPhilly/feat/test-harness
feat(test-harness): vitest across workspaces + test helpers
2 parents bc95f59 + 4650ae2 commit 77c79e2

19 files changed

Lines changed: 3483 additions & 92 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,8 @@ jobs:
2727
- name: Lint
2828
run: npm run lint
2929

30+
- name: Test
31+
run: npm test
32+
3033
- name: Build
3134
run: npm run build

apps/api/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
"dev": "tsx watch src/index.ts",
99
"build": "tsc -p tsconfig.json",
1010
"start": "node dist/index.js",
11-
"type-check": "tsc -p tsconfig.json --noEmit"
11+
"type-check": "tsc -p tsconfig.json --noEmit",
12+
"test": "vitest run"
1213
},
1314
"dependencies": {
1415
"fastify": "^5.8.5"
1516
},
1617
"devDependencies": {
1718
"@types/node": "^25.8.0",
19+
"gitsheets": "^1.0.3",
20+
"msw": "^2.14.6",
1821
"pino-pretty": "^13.1.3",
1922
"tsx": "^4.22.0",
2023
"typescript": "^6.0.3"

apps/api/tests/harness.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createTestRepo, seed } from './helpers/test-repo.js';
4+
import { createTestPrivateStore } from './helpers/test-private-store.js';
5+
6+
describe('test harness — api', () => {
7+
it('placeholder: arithmetic works', () => {
8+
expect(1 + 1).toBe(2);
9+
});
10+
11+
it('createTestRepo: create, upsert, queryFirst, cleanup', async () => {
12+
const { repo, cleanup } = await createTestRepo(['people']);
13+
try {
14+
await seed(repo, 'people', [{ slug: 'jane', name: 'Jane Doe' }]);
15+
const sheet = await repo.openSheet('people');
16+
const found = await sheet.queryFirst({ slug: 'jane' });
17+
expect(found).toBeDefined();
18+
expect(found?.slug).toBe('jane');
19+
} finally {
20+
await cleanup();
21+
}
22+
});
23+
24+
it('createTestPrivateStore: putProfile, getProfile, cleanup', async () => {
25+
const { store, cleanup } = await createTestPrivateStore();
26+
try {
27+
const profile = {
28+
personId: '01951a3c-0000-7000-8000-000000000001',
29+
email: 'jane@example.com',
30+
emailRefreshedAt: '2026-05-16T00:00:00Z',
31+
newsletter: { optedIn: false, optedInAt: null, unsubscribeToken: null },
32+
updatedAt: '2026-05-16T00:00:00Z',
33+
};
34+
await store.putProfile(profile);
35+
const retrieved = await store.getProfile(profile.personId);
36+
expect(retrieved).not.toBeNull();
37+
expect(retrieved?.email).toBe('jane@example.com');
38+
39+
const found = await store.findPersonIdByEmail('jane@example.com');
40+
expect(found).toBe(profile.personId);
41+
} finally {
42+
await cleanup();
43+
}
44+
});
45+
});

apps/api/tests/helpers/mocks.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { http, HttpResponse } from 'msw';
2+
import { setupServer } from 'msw/node';
3+
4+
/**
5+
* Minimal shape of a GitHub user object — only fields the API routes consume.
6+
* Extend as needed when auth routes are implemented.
7+
*/
8+
export interface GitHubUser {
9+
readonly id: number;
10+
readonly login: string;
11+
readonly name: string | null;
12+
readonly avatar_url: string;
13+
}
14+
15+
export interface GitHubEmail {
16+
readonly email: string;
17+
readonly primary: boolean;
18+
readonly verified: boolean;
19+
readonly visibility: string | null;
20+
}
21+
22+
/**
23+
* A captured outbound email send — inspectable in tests.
24+
*/
25+
export interface CapturedEmail {
26+
readonly to: string | string[];
27+
readonly from: string;
28+
readonly subject: string;
29+
readonly html?: string;
30+
readonly text?: string;
31+
}
32+
33+
/**
34+
* Build an MSW server that intercepts outbound HTTP to api.github.com.
35+
*
36+
* Usage:
37+
* const { server, setGitHubUser } = createGitHubMock();
38+
* beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
39+
* afterEach(() => server.resetHandlers());
40+
* afterAll(() => server.close());
41+
*/
42+
export function createGitHubMock(defaults?: {
43+
user?: GitHubUser;
44+
emails?: GitHubEmail[];
45+
}) {
46+
let currentUser: GitHubUser = defaults?.user ?? {
47+
id: 1,
48+
login: 'testuser',
49+
name: 'Test User',
50+
avatar_url: 'https://avatars.githubusercontent.com/u/1',
51+
};
52+
53+
let currentEmails: GitHubEmail[] = defaults?.emails ?? [
54+
{ email: 'testuser@example.com', primary: true, verified: true, visibility: 'public' },
55+
];
56+
57+
const server = setupServer(
58+
http.get('https://api.github.com/user', () =>
59+
HttpResponse.json(currentUser),
60+
),
61+
http.get('https://api.github.com/user/emails', () =>
62+
HttpResponse.json(currentEmails),
63+
),
64+
);
65+
66+
return {
67+
server,
68+
setGitHubUser(user: GitHubUser) {
69+
currentUser = user;
70+
},
71+
setGitHubEmails(emails: GitHubEmail[]) {
72+
currentEmails = emails;
73+
},
74+
};
75+
}
76+
77+
/**
78+
* No-op Resend mock. Intercepts POST /emails via MSW and collects sends
79+
* into an in-memory array for inspection. Does not call the real Resend API.
80+
*
81+
* Usage:
82+
* const { server, sentEmails } = createResendMock();
83+
* beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
84+
* afterEach(() => { server.resetHandlers(); sentEmails.length = 0; });
85+
* afterAll(() => server.close());
86+
*/
87+
export function createResendMock() {
88+
const sentEmails: CapturedEmail[] = [];
89+
90+
const server = setupServer(
91+
http.post('https://api.resend.com/emails', async ({ request }) => {
92+
const body = (await request.json()) as CapturedEmail;
93+
sentEmails.push(body);
94+
return HttpResponse.json({ id: `mock-${Date.now()}` }, { status: 200 });
95+
}),
96+
);
97+
98+
return { server, sentEmails };
99+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
5+
/**
6+
* Minimal PrivateProfile shape from specs/behaviors/private-storage.md.
7+
* The authoritative Zod schema lives in packages/shared once storage-foundation
8+
* lands; this is the structural subset the test helper needs to compile.
9+
*/
10+
export interface PrivateProfile {
11+
readonly personId: string;
12+
readonly email: string;
13+
readonly emailRefreshedAt: string;
14+
readonly newsletter: {
15+
readonly optedIn: boolean;
16+
readonly optedInAt: string | null;
17+
readonly unsubscribeToken: string | null;
18+
};
19+
readonly updatedAt: string;
20+
}
21+
22+
/**
23+
* Subset of the PrivateStore interface (specs/behaviors/private-storage.md).
24+
* Implements only the methods exercised by test helpers today; the full
25+
* interface lands with storage-foundation.
26+
*/
27+
export interface TestPrivateStore {
28+
putProfile(profile: PrivateProfile): Promise<void>;
29+
getProfile(personId: string): Promise<PrivateProfile | null>;
30+
findPersonIdByEmail(email: string): Promise<string | null>;
31+
}
32+
33+
export interface AppTestPrivateStore {
34+
readonly store: TestPrivateStore;
35+
/** Absolute path to the temp directory backing this store. */
36+
readonly path: string;
37+
/** Remove the temp directory. Idempotent. */
38+
readonly cleanup: () => Promise<void>;
39+
}
40+
41+
/**
42+
* Create a filesystem-backed PrivateStore fixture in a temp directory.
43+
*
44+
* Writes are atomic via temp-file-then-rename, matching the production
45+
* filesystem backend contract in specs/behaviors/private-storage.md.
46+
*
47+
* This shim implements the test-facing surface only. The production
48+
* filesystem and S3 backends land with the storage-foundation plan.
49+
*/
50+
export async function createTestPrivateStore(): Promise<AppTestPrivateStore> {
51+
const dir = await mkdtemp(join(tmpdir(), 'cfp-private-store-'));
52+
await mkdir(dir, { recursive: true });
53+
54+
const profilesPath = join(dir, 'profiles.jsonl');
55+
56+
const readProfiles = async (): Promise<Map<string, PrivateProfile>> => {
57+
const map = new Map<string, PrivateProfile>();
58+
let raw: string;
59+
try {
60+
raw = await readFile(profilesPath, 'utf8');
61+
} catch {
62+
return map;
63+
}
64+
for (const line of raw.split('\n')) {
65+
const trimmed = line.trim();
66+
if (!trimmed) continue;
67+
const record = JSON.parse(trimmed) as PrivateProfile;
68+
map.set(record.personId, record);
69+
}
70+
return map;
71+
};
72+
73+
const writeProfiles = async (profiles: Map<string, PrivateProfile>): Promise<void> => {
74+
const lines = [...profiles.values()].map((p) => JSON.stringify(p)).join('\n');
75+
const tmp = `${profilesPath}.tmp`;
76+
await writeFile(tmp, lines ? lines + '\n' : '', 'utf8');
77+
await rename(tmp, profilesPath);
78+
};
79+
80+
const store: TestPrivateStore = {
81+
async putProfile(profile) {
82+
const profiles = await readProfiles();
83+
profiles.set(profile.personId, profile);
84+
await writeProfiles(profiles);
85+
},
86+
87+
async getProfile(personId) {
88+
const profiles = await readProfiles();
89+
return profiles.get(personId) ?? null;
90+
},
91+
92+
async findPersonIdByEmail(email) {
93+
const profiles = await readProfiles();
94+
for (const profile of profiles.values()) {
95+
if (profile.email.toLowerCase() === email.toLowerCase()) {
96+
return profile.personId;
97+
}
98+
}
99+
return null;
100+
},
101+
};
102+
103+
let cleaned = false;
104+
return {
105+
store,
106+
path: dir,
107+
cleanup: async () => {
108+
if (cleaned) return;
109+
cleaned = true;
110+
await rm(dir, { recursive: true, force: true });
111+
},
112+
};
113+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { execFile } from 'node:child_process';
2+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { promisify } from 'node:util';
6+
7+
import { openRepo, type Repository } from 'gitsheets';
8+
9+
const exec = promisify(execFile);
10+
11+
type GitRunner = (...args: string[]) => Promise<{ stdout: string; stderr: string }>;
12+
13+
export interface AppTestRepo {
14+
readonly repo: Repository;
15+
/** Absolute path to the working tree. */
16+
readonly path: string;
17+
/** Remove the temp directory. Idempotent. */
18+
readonly cleanup: () => Promise<void>;
19+
}
20+
21+
/**
22+
* Create an isolated git repo in a tmpdir, commit a minimal
23+
* `.gitsheets/<name>.toml` for each sheet name provided, and return a
24+
* wired-up gitsheets Repository.
25+
*
26+
* Each sheet config uses a single-level `path = '${{ slug }}'` template
27+
* under a `root` matching the sheet name. This shape is sufficient for
28+
* placeholder tests and simple fixtures; real configs will come from
29+
* packages/shared once storage-foundation lands.
30+
*/
31+
export async function createTestRepo(
32+
sheetNames: readonly string[] = [],
33+
): Promise<AppTestRepo> {
34+
const dir = await mkdtemp(join(tmpdir(), 'cfp-test-'));
35+
const gitDir = join(dir, '.git');
36+
37+
const git: GitRunner = (...args) => exec('git', args, { cwd: dir });
38+
39+
await git('init', '-b', 'main');
40+
await git('config', 'user.email', 'test@cfp.test');
41+
await git('config', 'user.name', 'cfp test');
42+
await git('config', 'commit.gpgsign', 'false');
43+
await git('config', 'core.hooksPath', '/dev/null');
44+
await git('commit', '--allow-empty', '-m', 'initial');
45+
46+
if (sheetNames.length > 0) {
47+
await mkdir(join(dir, '.gitsheets'), { recursive: true });
48+
for (const name of sheetNames) {
49+
const config =
50+
`[gitsheet]\n` +
51+
`root = '${name}'\n` +
52+
`path = '\${{ slug }}'\n`;
53+
await writeFile(join(dir, '.gitsheets', `${name}.toml`), config);
54+
}
55+
await git('add', '.gitsheets');
56+
await git('commit', '-m', `chore: add sheet configs (${sheetNames.join(', ')})`);
57+
}
58+
59+
const repo = await openRepo({ gitDir, workTree: dir });
60+
61+
let cleaned = false;
62+
return {
63+
repo,
64+
path: dir,
65+
cleanup: async () => {
66+
if (cleaned) return;
67+
cleaned = true;
68+
await rm(dir, { recursive: true, force: true });
69+
},
70+
};
71+
}
72+
73+
/**
74+
* Upsert records into a named sheet inside a transaction.
75+
* Each record must contain a `slug` field (used by the path template).
76+
*/
77+
export async function seed(
78+
repo: Repository,
79+
sheetName: string,
80+
records: ReadonlyArray<Record<string, unknown>>,
81+
): Promise<void> {
82+
await repo.transact(
83+
{ message: `seed: ${sheetName}`, author: { name: 'test', email: 'test@cfp.test' } },
84+
async (tx) => {
85+
const sheet = tx.sheet(sheetName);
86+
for (const record of records) {
87+
await sheet.upsert(record);
88+
}
89+
},
90+
);
91+
}

apps/api/vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
environment: 'node',
6+
include: ['tests/**/*.test.ts'],
7+
},
8+
});

0 commit comments

Comments
 (0)