/** * Tests for handleModulePublish. * * Coverage: * - Validation error paths (no args, no token, bad --revision) * - Manifest reading failures * - Revision auto-detection algorithm * * The full build+publish flow is covered by integration tests. Here we focus * on the logic that runs before buildModule is called, which is where the * most subtle bugs live (revision calculation, version assembly). */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { IndexEntry } from '../../registry/client'; import { resetTestDbPath } from '../../test-utils/db-path'; import { handleModulePublish, resolveToken } from './module-publish'; const TEST_DIR = `/tmp/test-module-publish-${Date.now()}`; beforeEach(async () => { await mkdir(TEST_DIR, { recursive: true }); }); afterEach(async () => { await rm(TEST_DIR, { recursive: true, force: true }); }); // ── Validation error paths ──────────────────────────────────────────────────── describe('handleModulePublish — validation', () => { test('missing module dir returns usage error', async () => { const result = await handleModulePublish([], {}); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('Module directory required'); }); test('missing token returns error', async () => { // Isolate from the operator's real Celilo DB — otherwise the secret-store // fallback in resolveToken() may read a real publish token and the test // will skip the validation path it's trying to exercise. const origToken = process.env.CELILO_PUBLISH_TOKEN; const origDataDir = process.env.CELILO_DATA_DIR; delete process.env.CELILO_PUBLISH_TOKEN; process.env.CELILO_DB_PATH = `/tmp/test-celilo-no-token-${Date.now()}.db`; process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-${Date.now()}`; try { const result = await handleModulePublish([TEST_DIR], {}); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('Publish token required'); } finally { if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken; resetTestDbPath(); if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir; else delete process.env.CELILO_DATA_DIR; } }); test('--revision 0 is rejected', async () => { const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '0' }); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('--revision must be a positive integer'); }); test('--revision -1 is rejected', async () => { const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '-1' }); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('--revision must be a positive integer'); }); test('--revision non-integer string is rejected', async () => { const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: 'abc' }); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('--revision must be a positive integer'); }); }); // ── Manifest reading ────────────────────────────────────────────────────────── describe('handleModulePublish — manifest reading', () => { test('missing manifest.yml returns error', async () => { const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '1' }); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('Could not read manifest.yml'); }); test('manifest missing id returns error', async () => { await writeFile(join(TEST_DIR, 'manifest.yml'), 'version: "1.0.0"\n'); const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '1' }); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('manifest.yml missing id or version'); }); test('manifest missing version returns error', async () => { await writeFile(join(TEST_DIR, 'manifest.yml'), 'id: my-module\n'); const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '1' }); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('manifest.yml missing id or version'); }); test('a failed module does not stop the sweep — every module is attempted and every failure is named', async () => { // celilo#1369: the loop bailed at the first failed module, so modules // after it were never attempted (release run 6705 left nine modules // unpublished). Three dirs that each fail before any registry access, // at three different checks, prove the loop reaches all of them: the // returned error must name every dir, not only the first. const dirA = join(TEST_DIR, 'a-missing-id'); const dirB = join(TEST_DIR, 'b-missing-manifest'); const dirC = join(TEST_DIR, 'c-missing-version'); for (const d of [dirA, dirB, dirC]) await mkdir(d, { recursive: true }); await writeFile(join(dirA, 'manifest.yml'), 'version: "1.0.0"\n'); await writeFile(join(dirC, 'manifest.yml'), 'id: c-module\n'); const result = await handleModulePublish([dirA, dirB, dirC], { token: 'tok' }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain(dirA); expect(result.error).toContain(dirB); expect(result.error).toContain(dirC); } }); }); // ── Revision auto-detection algorithm ──────────────────────────────────────── // // This mirrors the exact logic in handleModulePublish. Tested here as a pure // function so regressions are caught without needing a running registry. function computeNextRevision(entries: IndexEntry[], baseVersion: string): number { const existingRevs = entries .filter((e) => e.vers.startsWith(`${baseVersion}+`)) .map((e) => Number(e.vers.split('+')[1])) .filter((n) => Number.isInteger(n)); return existingRevs.length > 0 ? Math.max(...existingRevs) + 1 : 1; } function entry(vers: string): IndexEntry { return { name: 'test', vers, deps: [], cksum: '', yanked: false }; } describe('revision auto-detection algorithm', () => { test('no existing entries → revision 1', () => { expect(computeNextRevision([], '1.0.0')).toBe(1); }); test('one existing entry → revision 2', () => { expect(computeNextRevision([entry('1.0.0+1')], '1.0.0')).toBe(2); }); test('multiple entries → max + 1', () => { const entries = [entry('1.0.0+1'), entry('1.0.0+3'), entry('1.0.0+2')]; expect(computeNextRevision(entries, '1.0.0')).toBe(4); }); test('entries for different base version are ignored', () => { const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('2.0.0+1')]; expect(computeNextRevision(entries, '2.0.0')).toBe(2); }); test('entries for different base version produce revision 1 for new base', () => { const entries = [entry('1.0.0+1'), entry('1.0.0+2')]; expect(computeNextRevision(entries, '2.0.0')).toBe(1); }); test('non-integer revision suffix is ignored', () => { const entries = [entry('1.0.0+bad'), entry('1.0.0+2')]; expect(computeNextRevision(entries, '1.0.0')).toBe(3); }); test('yanked entries still count toward revision numbering', () => { // Revision numbers are global — yanked versions still occupy their slot const entries = [entry('1.0.0+1'), entry('1.0.0+2')]; expect(computeNextRevision(entries, '1.0.0')).toBe(3); }); }); // validateCapabilityVersions tests live with the function in // services/module-validator/capability-versions.test.ts. // ── Token resolution: --token → env → secret store ──────────────────────────── describe('resolveToken — precedence', () => { test('--token flag wins over env var and secret store', async () => { const origEnv = process.env.CELILO_PUBLISH_TOKEN; process.env.CELILO_PUBLISH_TOKEN = 'env-token'; process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`; try { const t = await resolveToken('flag-token'); expect(t).toBe('flag-token'); } finally { if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv; else delete process.env.CELILO_PUBLISH_TOKEN; resetTestDbPath(); } }); test('env var used when --token flag is empty', async () => { const origEnv = process.env.CELILO_PUBLISH_TOKEN; process.env.CELILO_PUBLISH_TOKEN = 'env-token'; process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`; try { const t = await resolveToken(''); expect(t).toBe('env-token'); } finally { if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv; else delete process.env.CELILO_PUBLISH_TOKEN; resetTestDbPath(); } }); test('returns empty string when no source has a token', async () => { // All three sources empty: secret-store fallback hits an isolated empty // DB and returns null gracefully. const origEnv = process.env.CELILO_PUBLISH_TOKEN; const origDataDir = process.env.CELILO_DATA_DIR; delete process.env.CELILO_PUBLISH_TOKEN; process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`; process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-empty-${Date.now()}`; try { const t = await resolveToken(''); expect(t).toBe(''); } finally { if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv; resetTestDbPath(); if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir; else delete process.env.CELILO_DATA_DIR; } }); }); // ── Multi-module argument handling ──────────────────────────────────────────── describe('handleModulePublish — multi-module', () => { test('--revision combined with multiple module dirs is rejected', async () => { const result = await handleModulePublish(['./a', './b'], { token: 'tok', revision: '1', }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('--revision cannot be combined with multiple module dirs'); } }); });