moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,357 @@
/**
* 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);
}
});
});