diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1cd77046..3117ed2a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,6 +76,11 @@ jobs:
- name: Build packages
run: pnpm build
+ - name: Migrate test database
+ run: pnpm db:migrate
+ env:
+ DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
+
- name: Run tests and verify README test counts
run: node scripts/sync-test-count.mjs --check
env:
diff --git a/README.md b/README.md
index 1a0146e1..52ef4fcb 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -510,7 +510,8 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
-| Testing | Vitest (1602 tests across 223 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
+| Testing | Vitest (1630 passing tests across 228 files with passing tests) | Unit and integration tests |
+| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
@@ -642,7 +643,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# All tests (1602 tests across 223 test files)
+# Passing-test count: 1630 test cases across 228 files (skipped excluded)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1190,7 +1191,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
-pnpm test # Run all tests (1602 tests, 223 files)
+pnpm test # Run all tests (1630 passing, 228 files with passes; skipped excluded)
pnpm lint # ESLint
```
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 53dbb9ee..9235a5bf 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,5 +1,7 @@
{
- "tests": 1602,
- "files": 223,
- "updatedAt": "2026-08-27T10:58:08.378Z"
+ "tests": 1630,
+ "files": 228,
+ "scope": "all workspace packages with a test script",
+ "semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded",
+ "updatedAt": "2026-08-27T17:54:52.422Z"
}
diff --git a/packages/shared/src/sync-test-count-script.spec.js b/packages/shared/src/sync-test-count-script.spec.js
new file mode 100644
index 00000000..56cb08bd
--- /dev/null
+++ b/packages/shared/src/sync-test-count-script.spec.js
@@ -0,0 +1,142 @@
+import { describe, expect, it } from 'vitest';
+import {
+ applyCounts,
+ buildStatsDocument,
+ countPassedReport,
+ isReadmeOutOfDate,
+ selectTestPackagePaths,
+} from '../../../scripts/sync-test-count.mjs';
+
+const COUNTS = { tests: 10, files: 2 };
+
+function syncedReadmeFixture() {
+ return [
+ '',
+ '
',
+ '',
+ '| Tool | Notes | Purpose |',
+ '| --- | --- | --- |',
+ '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests |',
+ '| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |',
+ '',
+ '# Passing-test count: 10 test cases across 2 files (skipped excluded)',
+ '',
+ 'pnpm test # Run all tests (10 passing, 2 files with passes; skipped excluded)',
+ '',
+ ].join('\n');
+}
+
+describe('sync-test-count workspace coverage', () => {
+ it('selects every non-root workspace whose manifest defines the pnpm test surface', () => {
+ const manifests = new Map([
+ ['/repo/apps/api', { scripts: { test: 'vitest run' } }],
+ ['/repo/apps/dashboard', { scripts: { test: 'vitest run' } }],
+ ['/repo/packages/database', { scripts: { test: 'vitest run' } }],
+ ['/repo/packages/shared', { scripts: { test: 'vitest run' } }],
+ ['/repo/packages/no-tests', { scripts: { build: 'tsup' } }],
+ ]);
+ const workspaces = [
+ { path: '/repo' },
+ ...Array.from(manifests.keys(), (path) => ({ path })),
+ ];
+
+ expect(selectTestPackagePaths(
+ workspaces,
+ '/repo',
+ (path) => manifests.get(path),
+ )).toEqual([
+ '/repo/apps/api',
+ '/repo/apps/dashboard',
+ '/repo/packages/database',
+ '/repo/packages/shared',
+ ]);
+ });
+
+ it('counts passed test cases and only files with at least one passed test', () => {
+ expect(countPassedReport({
+ numPassedTests: 2,
+ testResults: [
+ { assertionResults: [{ status: 'passed' }, { status: 'skipped' }] },
+ { assertionResults: [{ status: 'skipped' }] },
+ { assertionResults: [] },
+ { assertionResults: [{ status: 'passed' }] },
+ ],
+ })).toEqual({ tests: 2, files: 2 });
+ });
+
+ it('publishes the test-case count scope and skipped-test semantics explicitly', () => {
+ expect(buildStatsDocument(
+ { tests: 12, files: 3 },
+ '2026-08-25T12:00:00.000Z',
+ )).toEqual({
+ tests: 12,
+ files: 3,
+ scope: 'all workspace packages with a test script',
+ semantics: 'passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded',
+ updatedAt: '2026-08-25T12:00:00.000Z',
+ });
+ });
+});
+
+describe('sync-test-count README applyCounts / --check coverage', () => {
+ it('is idempotent on a fully synced README (check would pass)', () => {
+ const readme = syncedReadmeFixture();
+ expect(applyCounts(readme, COUNTS)).toBe(readme);
+ expect(isReadmeOutOfDate(readme, COUNTS)).toBe(false);
+ });
+
+ it('fails check when the Tests badge is stale', () => {
+ const stale = syncedReadmeFixture().replace(
+ /Tests-10%20passing-brightgreen" alt="10 Tests Passing"/,
+ 'Tests-9%20passing-brightgreen" alt="9 Tests Passing"',
+ );
+ expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true);
+ expect(applyCounts(stale, COUNTS)).toContain('Tests-10%20passing');
+ });
+
+ it('fails check when the Passing-test heading is stale', () => {
+ const stale = syncedReadmeFixture().replace(
+ '# Passing-test count: 10 test cases across 2 files (skipped excluded)',
+ '# Passing-test count: 9 test cases across 2 files (skipped excluded)',
+ );
+ expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true);
+ expect(applyCounts(stale, COUNTS)).toContain(
+ '# Passing-test count: 10 test cases across 2 files (skipped excluded)',
+ );
+ });
+
+ it('fails check when the pnpm test command count is stale', () => {
+ const stale = syncedReadmeFixture().replace(
+ 'pnpm test # Run all tests (10 passing, 2 files with passes; skipped excluded)',
+ 'pnpm test # Run all tests (9 passing, 2 files with passes; skipped excluded)',
+ );
+ expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true);
+ expect(applyCounts(stale, COUNTS)).toContain(
+ 'pnpm test # Run all tests (10 passing, 2 files with passes; skipped excluded)',
+ );
+ });
+
+ it('fails check when Testing and Build remain concatenated on one row', () => {
+ const malformed = syncedReadmeFixture().replace(
+ '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests |\n| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |\n',
+ '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |\n',
+ );
+ expect(isReadmeOutOfDate(malformed, COUNTS)).toBe(true);
+ const fixed = applyCounts(malformed, COUNTS);
+ expect(fixed).toContain(
+ '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests |',
+ );
+ expect(fixed).toContain(
+ '| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |',
+ );
+ expect(fixed).not.toContain('tests || Build |');
+ });
+
+ it('fails check when only the Testing table row counts are stale', () => {
+ const stale = syncedReadmeFixture().replace(
+ 'Vitest (10 passing tests across 2 files with passing tests)',
+ 'Vitest (9 passing tests across 2 files with passing tests)',
+ );
+ expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true);
+ });
+});
diff --git a/scripts/sync-test-count.mjs b/scripts/sync-test-count.mjs
index b031e58d..4bce9761 100644
--- a/scripts/sync-test-count.mjs
+++ b/scripts/sync-test-count.mjs
@@ -1,16 +1,18 @@
#!/usr/bin/env node
/**
- * Run the workspace test suite and sync counts into README.md.
+ * Run the complete workspace test suite and sync passed counts into README.md
+ * and docs/test-stats.json. Skipped test cases and skipped-only files do not
+ * inflate the published totals.
*
* Usage:
* node scripts/sync-test-count.mjs # run tests, update README
- * node scripts/sync-test-count.mjs --check # run tests, fail if README is stale
+ * node scripts/sync-test-count.mjs --check # run tests, fail if published counts are stale
*
* Counts come from vitest's JSON reporter (reliable in CI; no log parsing).
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
-import { dirname, join } from 'node:path';
+import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -19,8 +21,6 @@ const readmePath = join(root, 'README.md');
const statsPath = join(root, 'docs/test-stats.json');
const countDir = join(root, '.vitest-count');
-const TEST_PACKAGES = ['apps/api', 'apps/dashboard', 'apps/booking'];
-
const args = process.argv.slice(2);
const checkOnly = args.includes('--check');
@@ -41,15 +41,57 @@ function countPassedFiles(report) {
}).length;
}
+export function countPassedReport(report) {
+ return {
+ tests: report.numPassedTests ?? 0,
+ files: countPassedFiles(report),
+ };
+}
+
+export function buildStatsDocument(counts, updatedAt = new Date().toISOString()) {
+ return {
+ ...counts,
+ scope: 'all workspace packages with a test script',
+ semantics:
+ 'passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded',
+ updatedAt,
+ };
+}
+
+export function selectTestPackagePaths(
+ workspaces,
+ workspaceRoot = root,
+ readManifest = (path) => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')),
+) {
+ return workspaces
+ .map((workspace) => workspace.path)
+ .filter((path) => resolve(path) !== resolve(workspaceRoot))
+ .filter((path) => Boolean(readManifest(path)?.scripts?.test));
+}
+
+function discoverTestPackagePaths() {
+ const result = spawnSync(
+ 'pnpm',
+ ['-r', 'list', '--depth', '-1', '--json'],
+ { cwd: root, env: testEnv(), encoding: 'utf8' },
+ );
+ if (result.status !== 0) {
+ throw new Error(
+ `Could not discover pnpm test workspaces: ${result.stderr || result.stdout || 'unknown error'}`,
+ );
+ }
+ return selectTestPackagePaths(JSON.parse(result.stdout));
+}
+
function runTestsAndCollectCounts() {
mkdirSync(countDir, { recursive: true });
const env = testEnv();
let tests = 0;
let files = 0;
- for (const pkg of TEST_PACKAGES) {
- const cwd = join(root, pkg);
- const outFile = join(countDir, `${pkg.replace('/', '-')}.json`);
+ for (const cwd of discoverTestPackagePaths()) {
+ const workspacePath = relative(root, cwd);
+ const outFile = join(countDir, `${workspacePath.replaceAll(/[\\/]/g, '-')}.json`);
const result = spawnSync(
'pnpm',
@@ -61,7 +103,7 @@ function runTestsAndCollectCounts() {
if (log.trim()) process.stdout.write(log);
if (result.status !== 0) {
- process.exit(result.status ?? 1);
+ throw new Error(`Tests failed in ${workspacePath}`);
}
if (!existsSync(outFile)) {
@@ -69,8 +111,9 @@ function runTestsAndCollectCounts() {
}
const report = JSON.parse(readFileSync(outFile, 'utf8'));
- tests += report.numPassedTests ?? 0;
- files += countPassedFiles(report);
+ const count = countPassedReport(report);
+ tests += count.tests;
+ files += count.files;
}
if (tests === 0 || files === 0) {
@@ -80,7 +123,11 @@ function runTestsAndCollectCounts() {
return { tests, files };
}
-function applyCounts(readme, { tests, files }) {
+/**
+ * Rewrite every README location owned by this synchronizer (badge, Testing
+ * table row, malformed-row normalization, heading, and `pnpm test` command).
+ */
+export function applyCounts(readme, { tests, files }) {
let next = readme;
if (!next.includes('img.shields.io/badge/Tests-')) {
@@ -95,54 +142,67 @@ function applyCounts(readme, { tests, files }) {
);
}
+ // Normalize legacy rows where Testing and Build were concatenated on one line.
+ next = next.replace(
+ /\| Testing \| Vitest \([^|]+\) \| Unit and integration tests \|\| Build \|[^|\n]+\|[^\n]*\n/,
+ `| Testing | Vitest (${tests} passing tests across ${files} files with passing tests) | Unit and integration tests |\n| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |\n`,
+ );
+
next = next.replace(
/\| Testing \| Vitest \([^|]+\) \| Unit and integration tests \|/,
- `| Testing | Vitest (${tests} tests across ${files} test files) | Unit and integration tests |`,
+ `| Testing | Vitest (${tests} passing tests across ${files} files with passing tests) | Unit and integration tests |`,
);
next = next.replace(
- /# All tests[^\n]*/,
- `# All tests (${tests} tests across ${files} test files)`,
+ /(?:# All tests|# Passing-test count:)[^\n]*/,
+ `# Passing-test count: ${tests} test cases across ${files} files (skipped excluded)`,
);
next = next.replace(
/pnpm test\s+# Run all tests[^\n]*/,
- `pnpm test # Run all tests (${tests} tests, ${files} files)`,
+ `pnpm test # Run all tests (${tests} passing, ${files} files with passes; skipped excluded)`,
);
return next;
}
-function readCountsFromReadme(readme) {
- const testsMatch = readme.match(
- /\| Testing \| Vitest \((\d+) tests across (\d+) test files\) \|/,
- );
- if (!testsMatch) {
- throw new Error('README is missing synced test counts — run: pnpm readme:sync-tests');
- }
- return { tests: Number(testsMatch[1]), files: Number(testsMatch[2]) };
+/** True when any synchronizer-owned README location differs from applyCounts output. */
+export function isReadmeOutOfDate(readme, counts) {
+ return applyCounts(readme, counts) !== readme;
}
-const counts = runTestsAndCollectCounts();
-const readme = readFileSync(readmePath, 'utf8');
-
-if (checkOnly) {
- const current = readCountsFromReadme(readme);
- if (current.tests !== counts.tests || current.files !== counts.files) {
- console.error(
- `README test counts are stale (readme: ${current.tests} tests / ${current.files} files, actual: ${counts.tests} / ${counts.files}).`,
- );
- console.error('Run: pnpm readme:sync-tests');
- process.exit(1);
+function main() {
+ const counts = runTestsAndCollectCounts();
+ const readme = readFileSync(readmePath, 'utf8');
+
+ if (checkOnly) {
+ const stats = JSON.parse(readFileSync(statsPath, 'utf8'));
+ const expectedStats = buildStatsDocument(counts, stats.updatedAt);
+ const readmeStale = isReadmeOutOfDate(readme, counts);
+ const statsStale = JSON.stringify(stats) !== JSON.stringify(expectedStats);
+ if (readmeStale || statsStale) {
+ throw new Error(
+ `Published test counts are stale (actual: ${counts.tests} / ${counts.files}). Run: pnpm readme:sync-tests`,
+ );
+ }
+ console.log(`Published test counts OK (${counts.tests} tests, ${counts.files} files)`);
+ return;
}
- console.log(`README test counts OK (${counts.tests} tests, ${counts.files} files)`);
- process.exit(0);
-}
-writeFileSync(
- statsPath,
- `${JSON.stringify({ ...counts, updatedAt: new Date().toISOString() }, null, 2)}\n`,
-);
+ writeFileSync(
+ statsPath,
+ `${JSON.stringify(buildStatsDocument(counts), null, 2)}\n`,
+ );
+
+ writeFileSync(readmePath, applyCounts(readme, counts));
+ console.log(`Synced README test counts: ${counts.tests} tests across ${counts.files} files`);
+}
-writeFileSync(readmePath, applyCounts(readme, counts));
-console.log(`Synced README test counts: ${counts.tests} tests across ${counts.files} files`);
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+ }
+}