godcrm/backend/services/starter-pack/__tests__/StarterPackService.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

203 lines
9.1 KiB
JavaScript

// ADR-0079 P4 — StarterPackService unit tests.
//
// Mocks `database/connection.js` so we exercise the service's logic
// (feature flag, idempotency probe, transactional sequence, promo
// unlock) without touching a real DB. Acceptance criteria covered:
// AC4 — Tier-A agent slugs hard-coded to 5 in catalog (catalog test)
// AC5 — MASTERMIND / MESHOK promo flips users.agent_config.unlocked_agent_slugs
// AC6 — Feature-flag-off path returns {skipped, reason:'feature_disabled'} — no writes
// IDEM — Re-running on an already-provisioned project no-ops
import { describe, it, expect, beforeEach, vi } from 'vitest';
import '../../../../backend/test/setup.js';
const mockDbGet = vi.fn();
const mockDbAll = vi.fn();
const mockDbRun = vi.fn();
const mockWithTransactionAsync = vi.fn();
vi.mock('../../../database/connection.js', () => ({
dbGet: (...a) => mockDbGet(...a),
dbAll: (...a) => mockDbAll(...a),
dbRun: (...a) => mockDbRun(...a),
withTransactionAsync: (cb) => mockWithTransactionAsync(cb),
toBool: (v) => (v ? 1 : 0),
safeJsonParse: (v, d) => {
try { return typeof v === 'string' ? JSON.parse(v) : v; }
catch { return d; }
}
}));
vi.mock('../../../utils/logger.js', () => ({
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
}));
const { applyStarterPack, applyPromoUnlock, __test } =
await import('../StarterPackService.js');
const { TIER_B_AGENT_SLUGS } = await import('../starterPackCatalog.js');
describe('StarterPackService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('isFeatureEnabled (kill-switch)', () => {
it('returns true when the row is missing (fail-open)', async () => {
mockDbGet.mockResolvedValueOnce(null);
await expect(__test.isFeatureEnabled()).resolves.toBe(true);
});
it('returns false when value is false', async () => {
mockDbGet.mockResolvedValueOnce({ value: false });
await expect(__test.isFeatureEnabled()).resolves.toBe(false);
});
it('parses string "true"', async () => {
mockDbGet.mockResolvedValueOnce({ value: 'true' });
await expect(__test.isFeatureEnabled()).resolves.toBe(true);
});
it('fails open on DB error', async () => {
mockDbGet.mockRejectedValueOnce(new Error('boom'));
await expect(__test.isFeatureEnabled()).resolves.toBe(true);
});
});
describe('applyStarterPack — feature flag off', () => {
it('skips with reason=feature_disabled when flag is false', async () => {
mockDbGet.mockResolvedValueOnce({ value: false });
const r = await applyStarterPack(42, 'Test User');
expect(r).toEqual({ skipped: true, reason: 'feature_disabled' });
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
});
});
describe('applyStarterPack — missing personal space', () => {
it('skips when the user has no personal Space yet', async () => {
mockDbGet
.mockResolvedValueOnce({ value: true }) // feature flag
.mockResolvedValueOnce(null); // findPersonalHome → no space
const r = await applyStarterPack(42, 'Test User');
expect(r).toEqual({ skipped: true, reason: 'no_personal_space' });
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
});
});
describe('applyStarterPack — idempotency', () => {
it('skips when starter tables already exist (reason=already_provisioned)', async () => {
mockDbGet
.mockResolvedValueOnce({ value: true }) // feature flag
.mockResolvedValueOnce({ id: 100, name: 'Personal Space' }) // space row
.mockResolvedValueOnce({ id: 200, name: 'Home' }); // project row
mockDbAll.mockResolvedValueOnce([{ name: '📔 Daily Log' }]); // already provisioned
const r = await applyStarterPack(42, 'Test User');
expect(r).toEqual({ skipped: true, reason: 'already_provisioned' });
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
});
});
describe('applyStarterPack — happy path', () => {
it('runs in a transaction and creates 6 tables + welcome widget + Tor conv', async () => {
mockDbGet
.mockResolvedValueOnce({ value: true })
.mockResolvedValueOnce({ id: 100, name: 'Personal Space' })
.mockResolvedValueOnce({ id: 200, name: 'My Tasks' });
mockDbAll.mockResolvedValueOnce([]); // no existing starter tables
// trx fakes: run returns auto-incrementing IDs; get returns the
// sequence the production code path actually consumes — project
// dashboard for per-table widgets, six "no existing widget" checks,
// then space-dashboard + welcome-widget probes, finally the Tor agent.
let runCount = 0;
const trxGet = vi.fn()
.mockResolvedValueOnce({ id: 3300 }) // pinStarterTableWidgets — project dashboard
.mockResolvedValueOnce(null) // table 1 — no existing widget
.mockResolvedValueOnce(null) // table 2
.mockResolvedValueOnce(null) // table 3
.mockResolvedValueOnce(null) // table 4
.mockResolvedValueOnce(null) // table 5
.mockResolvedValueOnce(null) // table 6
.mockResolvedValueOnce(null) // pinWelcomeWidget — space dashboard absent → insert
.mockResolvedValueOnce(null) // existing welcome widget → none
.mockResolvedValueOnce({ id: 77630, name: 'PES (@tor)' }); // Tor lookup
const trxRun = vi.fn(async () => ({ lastInsertRowid: ++runCount }));
mockWithTransactionAsync.mockImplementationOnce(async (cb) =>
cb({ get: trxGet, run: trxRun, all: vi.fn() })
);
const r = await applyStarterPack(42, 'Test User');
expect(r.success).toBe(true);
expect(r.tableIds).toHaveLength(6);
expect(r.spaceId).toBe(100);
expect(r.projectId).toBe(200);
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(1);
// renameToHome should have run UPDATE projects twice
// (settings flag + name rename when source is "My Tasks").
const updateProjects = trxRun.mock.calls.filter(([sql]) =>
/UPDATE\s+projects/i.test(sql)
);
expect(updateProjects.length).toBeGreaterThanOrEqual(2);
// First call must flip is_starter_home in settings JSON.
expect(updateProjects[0][0]).toMatch(/jsonb_set\([\s\S]*is_starter_home/i);
// Welcome widget INSERT must carry starter_tables_map (all 6 expected slugs).
// Per-table widget INSERTs come first in the call sequence; pick the
// welcome-specific one by matching its config payload, not just the SQL.
const widgetInsert = trxRun.mock.calls.find(
([sql, params]) =>
/INSERT INTO widgets/i.test(sql) &&
params.some((p) => typeof p === 'string' && p.includes('starter_tables_map'))
);
expect(widgetInsert, 'expected the welcome-widget INSERT to be issued').toBeDefined();
const widgetConfigJson = widgetInsert[1].find(
(p) => typeof p === 'string' && p.includes('starter_tables_map')
);
expect(widgetConfigJson, 'widget config must serialize starter_tables_map').toBeDefined();
const parsed = JSON.parse(widgetConfigJson);
expect(Object.keys(parsed.starter_tables_map).sort()).toEqual(
['daily-log', 'goals-and-projects', 'habits', 'ideas', 'people', 'wishlist']
);
// Every map entry must point at a numeric table id.
for (const id of Object.values(parsed.starter_tables_map)) {
expect(typeof id).toBe('number');
}
});
});
describe('applyPromoUnlock', () => {
it('does nothing when no promo code', async () => {
await expect(applyPromoUnlock(42, null)).resolves.toEqual([]);
expect(mockDbRun).not.toHaveBeenCalled();
});
it('does nothing for unknown promo code', async () => {
await expect(applyPromoUnlock(42, 'RANDOMCODE')).resolves.toEqual([]);
expect(mockDbRun).not.toHaveBeenCalled();
});
it('unlocks Tier-B for MASTERMIND', async () => {
mockDbRun.mockResolvedValueOnce({ rowCount: 1 });
const r = await applyPromoUnlock(42, 'MASTERMIND');
expect(r).toEqual(TIER_B_AGENT_SLUGS);
expect(mockDbRun).toHaveBeenCalledTimes(1);
const [sql, params] = mockDbRun.mock.calls[0];
expect(sql).toMatch(/UPDATE users\s+SET agent_config = jsonb_set/i);
expect(params).toEqual([TIER_B_AGENT_SLUGS, 42]);
});
it('unlocks Tier-B for MESHOK (case-insensitive)', async () => {
mockDbRun.mockResolvedValueOnce({ rowCount: 1 });
const r = await applyPromoUnlock(42, 'meshok');
expect(r).toEqual(TIER_B_AGENT_SLUGS);
expect(mockDbRun).toHaveBeenCalledTimes(1);
});
it('returns [] when DB write fails (best-effort)', async () => {
mockDbRun.mockRejectedValueOnce(new Error('boom'));
await expect(applyPromoUnlock(42, 'MASTERMIND')).resolves.toEqual([]);
});
});
});