358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
/**
|
|
* Marketplace Sync Engine — Tests
|
|
*
|
|
* Tests for POST /api/marketplace/sync endpoint, cron job registration,
|
|
* and graceful handling of unreachable sync sources.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify from 'fastify';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import { MarketplaceDB, MarketplaceSync, type SyncOptions } from '@waggle/marketplace';
|
|
import { marketplaceRoutes } from '../../src/local/routes/marketplace.js';
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function getRepoRoot(): string {
|
|
return path.resolve(__dirname, '..', '..', '..', '..');
|
|
}
|
|
|
|
function getMarketplaceDbPath(): string | null {
|
|
const dbPath = path.join(getRepoRoot(), 'packages', 'marketplace', 'marketplace.db');
|
|
return fs.existsSync(dbPath) ? dbPath : null;
|
|
}
|
|
|
|
function openSeedCopy(prefix = 'waggle-sync-seed-'): { db: MarketplaceDB; cleanup: () => void } | null {
|
|
const bundled = getMarketplaceDbPath();
|
|
if (!bundled) return null;
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
const dbPath = path.join(tmpDir, 'marketplace.db');
|
|
fs.copyFileSync(bundled, dbPath);
|
|
const db = new MarketplaceDB(dbPath);
|
|
return { db, cleanup: () => { db.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); } };
|
|
}
|
|
|
|
async function syncHermetically(sync: MarketplaceSync, options?: SyncOptions) {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({}),
|
|
text: async () => '',
|
|
});
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
try {
|
|
return await sync.syncAll(options);
|
|
} finally {
|
|
logSpy.mockRestore();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
}
|
|
|
|
// ── Module Export ────────────────────────────────────────────────────
|
|
|
|
describe('Marketplace Sync Module', () => {
|
|
it('MarketplaceSync class is importable from @waggle/marketplace', async () => {
|
|
const mod = await import('@waggle/marketplace');
|
|
expect(mod.MarketplaceSync).toBeDefined();
|
|
expect(typeof mod.MarketplaceSync).toBe('function');
|
|
});
|
|
});
|
|
|
|
// ── Sync Route ──────────────────────────────────────────────────────
|
|
|
|
describe('POST /api/marketplace/sync', () => {
|
|
it('sync route is registered in marketplace routes', async () => {
|
|
const mod = await import('../../src/local/routes/marketplace.js');
|
|
expect(mod.marketplaceRoutes).toBeDefined();
|
|
// The route handler function exists — deeper HTTP testing would require
|
|
// a full server instance which is validated by integration tests.
|
|
});
|
|
|
|
it('MarketplaceSync.syncAll returns result format', { timeout: 60_000 }, async () => {
|
|
const seed = openSeedCopy();
|
|
if (!seed) return;
|
|
const db = seed.db;
|
|
|
|
try {
|
|
const sync = new MarketplaceSync(db);
|
|
|
|
// syncAll will try to reach external APIs — which will fail in CI/local.
|
|
// The adapter return shape is verified through the hermetic boundary below.
|
|
const results = await syncHermetically(sync);
|
|
|
|
expect(Array.isArray(results)).toBe(true);
|
|
for (const result of results) {
|
|
expect(result).toHaveProperty('source');
|
|
expect(result).toHaveProperty('added');
|
|
expect(result).toHaveProperty('updated');
|
|
expect(result).toHaveProperty('removed');
|
|
expect(result).toHaveProperty('errors');
|
|
expect(typeof result.source).toBe('string');
|
|
expect(typeof result.added).toBe('number');
|
|
expect(typeof result.updated).toBe('number');
|
|
expect(typeof result.removed).toBe('number');
|
|
expect(Array.isArray(result.errors)).toBe(true);
|
|
}
|
|
} finally {
|
|
seed.cleanup();
|
|
}
|
|
});
|
|
|
|
it('sync with no reachable sources returns graceful errors', async () => {
|
|
const seed = openSeedCopy();
|
|
if (!seed) return;
|
|
const db = seed.db;
|
|
|
|
try {
|
|
const sync = new MarketplaceSync(db);
|
|
|
|
// All sources point to external APIs that won't be reachable in test
|
|
const results = await syncHermetically(sync);
|
|
|
|
// Should NOT throw — errors are captured per-source
|
|
expect(Array.isArray(results)).toBe(true);
|
|
|
|
// Most sources will have errors since external APIs are unreachable
|
|
const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
|
|
// At least some sources should have errors (unless all are somehow reachable)
|
|
// We don't assert totalErrors > 0 because some sources might succeed
|
|
expect(totalErrors).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
seed.cleanup();
|
|
}
|
|
});
|
|
|
|
it('sync aggregates results from multiple sources', async () => {
|
|
const seed = openSeedCopy();
|
|
if (!seed) return;
|
|
const db = seed.db;
|
|
|
|
try {
|
|
const sources = db.listSources();
|
|
const sync = new MarketplaceSync(db);
|
|
|
|
const results = await syncHermetically(sync);
|
|
|
|
// Should have one result per source
|
|
expect(results.length).toBe(sources.length);
|
|
|
|
// Each result's source should match a known source name
|
|
const sourceNames = sources.map(s => s.name);
|
|
for (const result of results) {
|
|
expect(sourceNames).toContain(result.source);
|
|
}
|
|
} finally {
|
|
seed.cleanup();
|
|
}
|
|
});
|
|
|
|
it('manual sync is a no-network no-op when marketplace sync is disabled', async () => {
|
|
const bundled = getMarketplaceDbPath();
|
|
if (!bundled) return;
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-sync-disabled-'));
|
|
const dbPath = path.join(tmpDir, 'marketplace.db');
|
|
fs.copyFileSync(bundled, dbPath);
|
|
|
|
const db = new MarketplaceDB(dbPath);
|
|
const server = Fastify({ logger: false });
|
|
server.decorate('marketplace', db);
|
|
await server.register(marketplaceRoutes);
|
|
|
|
const previousDisable = process.env.WAGGLE_DISABLE_MARKETPLACE_SYNC;
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error('network should not be called when sync is disabled'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
process.env.WAGGLE_DISABLE_MARKETPLACE_SYNC = '1';
|
|
|
|
try {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/marketplace/sync',
|
|
payload: {},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toMatchObject({
|
|
skipped: true,
|
|
sourcesChecked: 0,
|
|
packagesAdded: 0,
|
|
packagesUpdated: 0,
|
|
errors: [],
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previousDisable === undefined) delete process.env.WAGGLE_DISABLE_MARKETPLACE_SYNC;
|
|
else process.env.WAGGLE_DISABLE_MARKETPLACE_SYNC = previousDisable;
|
|
vi.unstubAllGlobals();
|
|
await server.close();
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Sync Cron Registration ──────────────────────────────────────────
|
|
|
|
describe('Marketplace sync cron job', () => {
|
|
it('server index.ts handles marketplace_sync cron action', () => {
|
|
const indexPath = path.join(getRepoRoot(), 'packages', 'server', 'src', 'local', 'index.ts');
|
|
const content = fs.readFileSync(indexPath, 'utf-8');
|
|
|
|
// Verify that marketplace_sync action is handled in cron dispatcher
|
|
expect(content).toContain('marketplace_sync');
|
|
expect(content).toContain('Marketplace sync');
|
|
});
|
|
|
|
it('cron handler routes marketplace_sync action to MarketplaceSync', () => {
|
|
const indexPath = path.join(getRepoRoot(), 'packages', 'server', 'src', 'local', 'index.ts');
|
|
const content = fs.readFileSync(indexPath, 'utf-8');
|
|
|
|
// Verify the cron executor handles marketplace_sync
|
|
expect(content).toContain("mcJobConfig.action === 'marketplace_sync'");
|
|
expect(content).toContain('new MarketplaceSync');
|
|
});
|
|
|
|
it('MarketplaceSync import is available in server index', () => {
|
|
const indexPath = path.join(getRepoRoot(), 'packages', 'server', 'src', 'local', 'index.ts');
|
|
const content = fs.readFileSync(indexPath, 'utf-8');
|
|
|
|
expect(content).toContain('MarketplaceSync');
|
|
expect(content).toContain("from '@waggle/marketplace'");
|
|
});
|
|
});
|
|
|
|
// ── Sync Result Shape ───────────────────────────────────────────────
|
|
|
|
describe('Sync result aggregation', () => {
|
|
it('sync results can be aggregated into endpoint response format', async () => {
|
|
const seed = openSeedCopy();
|
|
if (!seed) return;
|
|
const db = seed.db;
|
|
|
|
try {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await syncHermetically(sync);
|
|
|
|
// Simulate the endpoint aggregation logic
|
|
const sourcesChecked = results.length;
|
|
const packagesAdded = results.reduce((sum, r) => sum + r.added, 0);
|
|
const packagesUpdated = results.reduce((sum, r) => sum + r.updated, 0);
|
|
const errors = results.flatMap(r => r.errors.map(e => `[${r.source}] ${e}`));
|
|
|
|
expect(typeof sourcesChecked).toBe('number');
|
|
expect(typeof packagesAdded).toBe('number');
|
|
expect(typeof packagesUpdated).toBe('number');
|
|
expect(Array.isArray(errors)).toBe(true);
|
|
expect(sourcesChecked).toBeGreaterThan(0);
|
|
} finally {
|
|
seed.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Sync Endpoint Contract (mocked, isolated) ──────────────────────
|
|
|
|
describe('POST /api/marketplace/sync — endpoint contract (mocked)', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-sync-ep-'));
|
|
const dbPath = path.join(tmpDir, 'marketplace.db');
|
|
const bundled = getMarketplaceDbPath();
|
|
if (!bundled) return;
|
|
fs.copyFileSync(bundled, dbPath);
|
|
db = new MarketplaceDB(dbPath);
|
|
|
|
// Mock fetch to prevent real network calls
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({}),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (db) db.close();
|
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('endpoint response has all required fields', async () => {
|
|
if (!db) return;
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await syncHermetically(sync);
|
|
|
|
// Replicate the exact route handler logic from marketplace.ts
|
|
const sourcesChecked = results.length;
|
|
const packagesAdded = results.reduce((sum, r) => sum + r.added, 0);
|
|
const packagesUpdated = results.reduce((sum, r) => sum + r.updated, 0);
|
|
const errors = results.flatMap(r => r.errors.map(e => `[${r.source}] ${e}`));
|
|
|
|
const response = {
|
|
sourcesChecked,
|
|
packagesAdded,
|
|
packagesUpdated,
|
|
errors,
|
|
details: results,
|
|
};
|
|
|
|
// Validate the full contract
|
|
expect(response).toHaveProperty('sourcesChecked');
|
|
expect(response).toHaveProperty('packagesAdded');
|
|
expect(response).toHaveProperty('packagesUpdated');
|
|
expect(response).toHaveProperty('errors');
|
|
expect(response).toHaveProperty('details');
|
|
expect(response.sourcesChecked).toBeGreaterThanOrEqual(40);
|
|
expect(response.details.length).toBeGreaterThanOrEqual(40);
|
|
expect(typeof response.packagesAdded).toBe('number');
|
|
expect(typeof response.packagesUpdated).toBe('number');
|
|
expect(Array.isArray(response.errors)).toBe(true);
|
|
});
|
|
|
|
it('filtered sync only processes requested sources', async () => {
|
|
if (!db) return;
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await syncHermetically(sync, { sources: ['clawhub'] });
|
|
|
|
const response = {
|
|
sourcesChecked: results.length,
|
|
packagesAdded: results.reduce((sum, r) => sum + r.added, 0),
|
|
packagesUpdated: results.reduce((sum, r) => sum + r.updated, 0),
|
|
errors: results.flatMap(r => r.errors.map(e => `[${r.source}] ${e}`)),
|
|
details: results,
|
|
};
|
|
|
|
expect(response.sourcesChecked).toBe(1);
|
|
expect(response.details[0].source).toBe('clawhub');
|
|
});
|
|
|
|
it('each detail entry in response has SyncResult shape', async () => {
|
|
if (!db) return;
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await syncHermetically(sync, { sources: ['clawhub', 'anthropics-skills'] });
|
|
|
|
for (const detail of results) {
|
|
expect(detail).toHaveProperty('source');
|
|
expect(detail).toHaveProperty('added');
|
|
expect(detail).toHaveProperty('updated');
|
|
expect(detail).toHaveProperty('removed');
|
|
expect(detail).toHaveProperty('errors');
|
|
expect(typeof detail.source).toBe('string');
|
|
expect(typeof detail.added).toBe('number');
|
|
expect(typeof detail.updated).toBe('number');
|
|
expect(typeof detail.removed).toBe('number');
|
|
expect(Array.isArray(detail.errors)).toBe(true);
|
|
}
|
|
});
|
|
});
|