Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ jobs:
- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build
run: npm run build
5 changes: 4 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"type-check": "tsc -p tsconfig.json --noEmit"
"type-check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"fastify": "^5.8.5"
},
"devDependencies": {
"@types/node": "^25.8.0",
"gitsheets": "^1.0.3",
"msw": "^2.14.6",
"pino-pretty": "^13.1.3",
"tsx": "^4.22.0",
"typescript": "^6.0.3"
Expand Down
45 changes: 45 additions & 0 deletions apps/api/tests/harness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';

import { createTestRepo, seed } from './helpers/test-repo.js';
import { createTestPrivateStore } from './helpers/test-private-store.js';

describe('test harness — api', () => {
it('placeholder: arithmetic works', () => {
expect(1 + 1).toBe(2);
});

it('createTestRepo: create, upsert, queryFirst, cleanup', async () => {
const { repo, cleanup } = await createTestRepo(['people']);
try {
await seed(repo, 'people', [{ slug: 'jane', name: 'Jane Doe' }]);
const sheet = await repo.openSheet('people');
const found = await sheet.queryFirst({ slug: 'jane' });
expect(found).toBeDefined();
expect(found?.slug).toBe('jane');
} finally {
await cleanup();
}
});

it('createTestPrivateStore: putProfile, getProfile, cleanup', async () => {
const { store, cleanup } = await createTestPrivateStore();
try {
const profile = {
personId: '01951a3c-0000-7000-8000-000000000001',
email: 'jane@example.com',
emailRefreshedAt: '2026-05-16T00:00:00Z',
newsletter: { optedIn: false, optedInAt: null, unsubscribeToken: null },
updatedAt: '2026-05-16T00:00:00Z',
};
await store.putProfile(profile);
const retrieved = await store.getProfile(profile.personId);
expect(retrieved).not.toBeNull();
expect(retrieved?.email).toBe('jane@example.com');

const found = await store.findPersonIdByEmail('jane@example.com');
expect(found).toBe(profile.personId);
} finally {
await cleanup();
}
});
});
99 changes: 99 additions & 0 deletions apps/api/tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

/**
* Minimal shape of a GitHub user object — only fields the API routes consume.
* Extend as needed when auth routes are implemented.
*/
export interface GitHubUser {
readonly id: number;
readonly login: string;
readonly name: string | null;
readonly avatar_url: string;
}

export interface GitHubEmail {
readonly email: string;
readonly primary: boolean;
readonly verified: boolean;
readonly visibility: string | null;
}

/**
* A captured outbound email send — inspectable in tests.
*/
export interface CapturedEmail {
readonly to: string | string[];
readonly from: string;
readonly subject: string;
readonly html?: string;
readonly text?: string;
}

/**
* Build an MSW server that intercepts outbound HTTP to api.github.com.
*
* Usage:
* const { server, setGitHubUser } = createGitHubMock();
* beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
* afterEach(() => server.resetHandlers());
* afterAll(() => server.close());
*/
export function createGitHubMock(defaults?: {
user?: GitHubUser;
emails?: GitHubEmail[];
}) {
let currentUser: GitHubUser = defaults?.user ?? {
id: 1,
login: 'testuser',
name: 'Test User',
avatar_url: 'https://avatars.githubusercontent.com/u/1',
};

let currentEmails: GitHubEmail[] = defaults?.emails ?? [
{ email: 'testuser@example.com', primary: true, verified: true, visibility: 'public' },
];

const server = setupServer(
http.get('https://api.github.com/user', () =>
HttpResponse.json(currentUser),
),
http.get('https://api.github.com/user/emails', () =>
HttpResponse.json(currentEmails),
),
);

return {
server,
setGitHubUser(user: GitHubUser) {
currentUser = user;
},
setGitHubEmails(emails: GitHubEmail[]) {
currentEmails = emails;
},
};
}

/**
* No-op Resend mock. Intercepts POST /emails via MSW and collects sends
* into an in-memory array for inspection. Does not call the real Resend API.
*
* Usage:
* const { server, sentEmails } = createResendMock();
* beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
* afterEach(() => { server.resetHandlers(); sentEmails.length = 0; });
* afterAll(() => server.close());
*/
export function createResendMock() {
const sentEmails: CapturedEmail[] = [];

const server = setupServer(
http.post('https://api.resend.com/emails', async ({ request }) => {
const body = (await request.json()) as CapturedEmail;
sentEmails.push(body);
return HttpResponse.json({ id: `mock-${Date.now()}` }, { status: 200 });
}),
);

return { server, sentEmails };
}
113 changes: 113 additions & 0 deletions apps/api/tests/helpers/test-private-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/**
* Minimal PrivateProfile shape from specs/behaviors/private-storage.md.
* The authoritative Zod schema lives in packages/shared once storage-foundation
* lands; this is the structural subset the test helper needs to compile.
*/
export interface PrivateProfile {
readonly personId: string;
readonly email: string;
readonly emailRefreshedAt: string;
readonly newsletter: {
readonly optedIn: boolean;
readonly optedInAt: string | null;
readonly unsubscribeToken: string | null;
};
readonly updatedAt: string;
}

/**
* Subset of the PrivateStore interface (specs/behaviors/private-storage.md).
* Implements only the methods exercised by test helpers today; the full
* interface lands with storage-foundation.
*/
export interface TestPrivateStore {
putProfile(profile: PrivateProfile): Promise<void>;
getProfile(personId: string): Promise<PrivateProfile | null>;
findPersonIdByEmail(email: string): Promise<string | null>;
}

export interface AppTestPrivateStore {
readonly store: TestPrivateStore;
/** Absolute path to the temp directory backing this store. */
readonly path: string;
/** Remove the temp directory. Idempotent. */
readonly cleanup: () => Promise<void>;
}

/**
* Create a filesystem-backed PrivateStore fixture in a temp directory.
*
* Writes are atomic via temp-file-then-rename, matching the production
* filesystem backend contract in specs/behaviors/private-storage.md.
*
* This shim implements the test-facing surface only. The production
* filesystem and S3 backends land with the storage-foundation plan.
*/
export async function createTestPrivateStore(): Promise<AppTestPrivateStore> {
const dir = await mkdtemp(join(tmpdir(), 'cfp-private-store-'));
await mkdir(dir, { recursive: true });

const profilesPath = join(dir, 'profiles.jsonl');

const readProfiles = async (): Promise<Map<string, PrivateProfile>> => {
const map = new Map<string, PrivateProfile>();
let raw: string;
try {
raw = await readFile(profilesPath, 'utf8');
} catch {
return map;
}
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed) as PrivateProfile;
map.set(record.personId, record);
}
return map;
};

const writeProfiles = async (profiles: Map<string, PrivateProfile>): Promise<void> => {
const lines = [...profiles.values()].map((p) => JSON.stringify(p)).join('\n');
const tmp = `${profilesPath}.tmp`;
await writeFile(tmp, lines ? lines + '\n' : '', 'utf8');
await rename(tmp, profilesPath);
};

const store: TestPrivateStore = {
async putProfile(profile) {
const profiles = await readProfiles();
profiles.set(profile.personId, profile);
await writeProfiles(profiles);
},

async getProfile(personId) {
const profiles = await readProfiles();
return profiles.get(personId) ?? null;
},

async findPersonIdByEmail(email) {
const profiles = await readProfiles();
for (const profile of profiles.values()) {
if (profile.email.toLowerCase() === email.toLowerCase()) {
return profile.personId;
}
}
return null;
},
};

let cleaned = false;
return {
store,
path: dir,
cleanup: async () => {
if (cleaned) return;
cleaned = true;
await rm(dir, { recursive: true, force: true });
},
};
}
91 changes: 91 additions & 0 deletions apps/api/tests/helpers/test-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';

import { openRepo, type Repository } from 'gitsheets';

const exec = promisify(execFile);

type GitRunner = (...args: string[]) => Promise<{ stdout: string; stderr: string }>;

export interface AppTestRepo {
readonly repo: Repository;
/** Absolute path to the working tree. */
readonly path: string;
/** Remove the temp directory. Idempotent. */
readonly cleanup: () => Promise<void>;
}

/**
* Create an isolated git repo in a tmpdir, commit a minimal
* `.gitsheets/<name>.toml` for each sheet name provided, and return a
* wired-up gitsheets Repository.
*
* Each sheet config uses a single-level `path = '${{ slug }}'` template
* under a `root` matching the sheet name. This shape is sufficient for
* placeholder tests and simple fixtures; real configs will come from
* packages/shared once storage-foundation lands.
*/
export async function createTestRepo(
sheetNames: readonly string[] = [],
): Promise<AppTestRepo> {
const dir = await mkdtemp(join(tmpdir(), 'cfp-test-'));
const gitDir = join(dir, '.git');

const git: GitRunner = (...args) => exec('git', args, { cwd: dir });

await git('init', '-b', 'main');
await git('config', 'user.email', 'test@cfp.test');
await git('config', 'user.name', 'cfp test');
await git('config', 'commit.gpgsign', 'false');
await git('config', 'core.hooksPath', '/dev/null');
await git('commit', '--allow-empty', '-m', 'initial');

if (sheetNames.length > 0) {
await mkdir(join(dir, '.gitsheets'), { recursive: true });
for (const name of sheetNames) {
const config =
`[gitsheet]\n` +
`root = '${name}'\n` +
`path = '\${{ slug }}'\n`;
await writeFile(join(dir, '.gitsheets', `${name}.toml`), config);
}
await git('add', '.gitsheets');
await git('commit', '-m', `chore: add sheet configs (${sheetNames.join(', ')})`);
}

const repo = await openRepo({ gitDir, workTree: dir });

let cleaned = false;
return {
repo,
path: dir,
cleanup: async () => {
if (cleaned) return;
cleaned = true;
await rm(dir, { recursive: true, force: true });
},
};
}

/**
* Upsert records into a named sheet inside a transaction.
* Each record must contain a `slug` field (used by the path template).
*/
export async function seed(
repo: Repository,
sheetName: string,
records: ReadonlyArray<Record<string, unknown>>,
): Promise<void> {
await repo.transact(
{ message: `seed: ${sheetName}`, author: { name: 'test', email: 'test@cfp.test' } },
async (tx) => {
const sheet = tx.sheet(sheetName);
for (const record of records) {
await sheet.upsert(record);
}
},
);
}
8 changes: 8 additions & 0 deletions apps/api/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
},
});
Loading