677 lines
21 KiB
TypeScript
677 lines
21 KiB
TypeScript
/**
|
|
* Marketplace Sync Engine — Verification Tests
|
|
*
|
|
* Tests that validate the MarketplaceSync engine can be instantiated,
|
|
* sources are populated, URLs are well-formed, and sync results have
|
|
* the correct shape.
|
|
*
|
|
* Uses a temporary SQLite database (not the real ~/.waggle/marketplace.db)
|
|
* and mocks global fetch to avoid real network calls.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import Database from 'better-sqlite3';
|
|
import { MarketplaceDB } from '../src/db';
|
|
import { MarketplaceSync } from '../src/sync';
|
|
import type { SyncResult, MarketplaceSource } from '../src/types';
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function getRepoRoot(): string {
|
|
return path.resolve(__dirname, '..', '..', '..');
|
|
}
|
|
|
|
function getBundledDbPath(): string {
|
|
return path.join(getRepoRoot(), 'packages', 'marketplace', 'marketplace.db');
|
|
}
|
|
|
|
/**
|
|
* Create a temp marketplace DB by copying the bundled one.
|
|
* This ensures tests operate on a disposable copy with all
|
|
* schema + seed data intact.
|
|
*/
|
|
function createTempDb(): { db: MarketplaceDB; tmpDir: string; dbPath: string } {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-mkt-sync-'));
|
|
const dbPath = path.join(tmpDir, 'marketplace.db');
|
|
fs.copyFileSync(getBundledDbPath(), dbPath);
|
|
const db = new MarketplaceDB(dbPath);
|
|
return { db, tmpDir, dbPath };
|
|
}
|
|
|
|
/**
|
|
* Create a temp marketplace DB from scratch with minimal schema.
|
|
* Used for tests that need an empty DB or controlled seed data.
|
|
*/
|
|
function createEmptyTempDb(): { db: MarketplaceDB; tmpDir: string; dbPath: string } {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-mkt-sync-empty-'));
|
|
const dbPath = path.join(tmpDir, 'marketplace.db');
|
|
|
|
// Create the DB with required schema
|
|
const raw = new Database(dbPath);
|
|
raw.pragma('journal_mode = WAL');
|
|
raw.pragma('foreign_keys = ON');
|
|
|
|
raw.exec(`
|
|
CREATE TABLE meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
url TEXT,
|
|
source_type TEXT NOT NULL,
|
|
platform TEXT NOT NULL,
|
|
total_packages INTEGER DEFAULT 0,
|
|
install_method TEXT,
|
|
api_endpoint TEXT,
|
|
description TEXT,
|
|
last_synced_at TEXT
|
|
);
|
|
|
|
CREATE TABLE packages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER REFERENCES sources(id),
|
|
name TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
description TEXT,
|
|
author TEXT,
|
|
package_type TEXT NOT NULL,
|
|
waggle_install_type TEXT NOT NULL,
|
|
waggle_install_path TEXT,
|
|
version TEXT DEFAULT '1.0.0',
|
|
license TEXT,
|
|
repository_url TEXT,
|
|
homepage_url TEXT,
|
|
downloads INTEGER DEFAULT 0,
|
|
stars INTEGER DEFAULT 0,
|
|
rating REAL DEFAULT 0,
|
|
rating_count INTEGER DEFAULT 0,
|
|
category TEXT,
|
|
subcategory TEXT,
|
|
install_manifest JSON,
|
|
platforms JSON DEFAULT '[]',
|
|
min_waggle_version TEXT,
|
|
dependencies JSON DEFAULT '[]',
|
|
packs JSON DEFAULT '[]',
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
security_status TEXT DEFAULT 'unscanned',
|
|
security_score INTEGER DEFAULT -1,
|
|
last_scanned_at TEXT,
|
|
content_hash TEXT,
|
|
scan_engines JSON,
|
|
scan_findings JSON,
|
|
scan_blocked BOOLEAN DEFAULT 0,
|
|
UNIQUE(source_id, name)
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE packages_fts USING fts5(
|
|
name, display_name, description, author, category,
|
|
content='packages',
|
|
content_rowid='id'
|
|
);
|
|
|
|
CREATE TABLE packs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
description TEXT,
|
|
target_roles TEXT,
|
|
icon TEXT,
|
|
priority TEXT DEFAULT 'MEDIUM',
|
|
connectors_needed JSON DEFAULT '[]',
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE pack_packages (
|
|
pack_id INTEGER REFERENCES packs(id),
|
|
package_id INTEGER REFERENCES packages(id),
|
|
is_core BOOLEAN DEFAULT 0,
|
|
PRIMARY KEY (pack_id, package_id)
|
|
);
|
|
|
|
CREATE TABLE tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL
|
|
);
|
|
|
|
CREATE TABLE package_tags (
|
|
package_id INTEGER REFERENCES packages(id),
|
|
tag_id INTEGER REFERENCES tags(id),
|
|
PRIMARY KEY (package_id, tag_id)
|
|
);
|
|
|
|
CREATE TABLE installations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
package_id INTEGER REFERENCES packages(id),
|
|
installed_version TEXT NOT NULL,
|
|
installed_at TEXT DEFAULT (datetime('now')),
|
|
install_path TEXT NOT NULL,
|
|
status TEXT DEFAULT 'active',
|
|
config JSON DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE scan_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
package_id INTEGER REFERENCES packages(id),
|
|
scanned_at TEXT DEFAULT (datetime('now')),
|
|
overall_severity TEXT NOT NULL,
|
|
security_score INTEGER NOT NULL,
|
|
content_hash TEXT,
|
|
engines_used JSON,
|
|
findings JSON,
|
|
blocked BOOLEAN DEFAULT 0,
|
|
scan_duration_ms INTEGER,
|
|
triggered_by TEXT DEFAULT 'manual'
|
|
);
|
|
|
|
CREATE TABLE security_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
|
|
raw.close();
|
|
|
|
const db = new MarketplaceDB(dbPath);
|
|
return { db, tmpDir, dbPath };
|
|
}
|
|
|
|
// ── Task 1: Instantiation ───────────────────────────────────────────
|
|
|
|
describe('MarketplaceSync — Instantiation', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
const ctx = createTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('can be instantiated with a MarketplaceDB', () => {
|
|
const sync = new MarketplaceSync(db);
|
|
expect(sync).toBeDefined();
|
|
expect(sync).toBeInstanceOf(MarketplaceSync);
|
|
});
|
|
|
|
it('can be instantiated with an empty temp DB', () => {
|
|
const empty = createEmptyTempDb();
|
|
try {
|
|
const sync = new MarketplaceSync(empty.db);
|
|
expect(sync).toBeDefined();
|
|
} finally {
|
|
empty.db.close();
|
|
fs.rmSync(empty.tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Task 1: Source Audit ────────────────────────────────────────────
|
|
|
|
describe('MarketplaceSync — Source Audit', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
let sources: MarketplaceSource[];
|
|
|
|
beforeEach(() => {
|
|
const ctx = createTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
sources = db.listSources();
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('sources are populated in the DB on init', () => {
|
|
expect(sources.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('source count is reasonable (>10)', () => {
|
|
expect(sources.length).toBeGreaterThan(10);
|
|
});
|
|
|
|
it('source count matches expected seeded sources (40+)', () => {
|
|
expect(sources.length).toBeGreaterThanOrEqual(40);
|
|
});
|
|
|
|
it('each source has name, url, and source_type', () => {
|
|
for (const source of sources) {
|
|
expect(source.name).toBeTruthy();
|
|
expect(typeof source.name).toBe('string');
|
|
expect(source.url).toBeDefined();
|
|
expect(typeof source.source_type).toBe('string');
|
|
expect(source.source_type.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('each source has a display_name', () => {
|
|
for (const source of sources) {
|
|
expect(source.display_name).toBeTruthy();
|
|
expect(typeof source.display_name).toBe('string');
|
|
}
|
|
});
|
|
|
|
it('source names are unique', () => {
|
|
const names = sources.map(s => s.name);
|
|
const uniqueNames = new Set(names);
|
|
expect(uniqueNames.size).toBe(names.length);
|
|
});
|
|
|
|
it('source URLs are well-formed (valid URL or null)', () => {
|
|
for (const source of sources) {
|
|
if (source.url) {
|
|
// Should not throw — valid URL
|
|
expect(() => new URL(source.url)).not.toThrow();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('source api_endpoints are well-formed when present', () => {
|
|
const withEndpoints = sources.filter(s => s.api_endpoint);
|
|
for (const source of withEndpoints) {
|
|
expect(() => new URL(source.api_endpoint!)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it('source_type values are from the expected set', () => {
|
|
const validTypes = [
|
|
'official_marketplace', 'community_repo', 'commercial_marketplace',
|
|
'aggregator', 'tool', 'specification', 'marketplace', 'registry',
|
|
'github_org', 'curated_list',
|
|
// Added 2026-05-21 — npm registry adapters landed as a new source_type
|
|
// when the npm-mcp-servers / npm-mcp-protocol sources were seeded.
|
|
'npm_registry',
|
|
];
|
|
for (const source of sources) {
|
|
expect(validTypes).toContain(source.source_type);
|
|
}
|
|
});
|
|
|
|
it('includes expected key sources', () => {
|
|
const names = sources.map(s => s.name);
|
|
// Core Anthropic sources
|
|
expect(names).toContain('anthropics-skills');
|
|
// Community marketplaces
|
|
expect(names).toContain('clawhub');
|
|
expect(names).toContain('skillsmp');
|
|
expect(names).toContain('lobehub');
|
|
});
|
|
|
|
it('GitHub-based sources have github.com in their URL', () => {
|
|
// Filter by URL — name-based heuristics produced false positives
|
|
// (e.g. `awesome-skills-app` is named "awesome" but hosted at awesome-skills.app, not GitHub).
|
|
// The "is this a GitHub source" question is best answered by the URL itself.
|
|
const githubSources = sources.filter(s =>
|
|
(s.url && s.url.includes('github.com')) ||
|
|
s.name.includes('anthropics') ||
|
|
s.name.startsWith('github-'),
|
|
);
|
|
for (const source of githubSources) {
|
|
if (source.url && source.url.startsWith('http')) {
|
|
expect(source.url).toContain('github.com');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Task 3: Sync Shape (mocked fetch) ──────────────────────────────
|
|
|
|
describe('MarketplaceSync — syncAll shape (mocked)', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
const ctx = createTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
|
|
// Mock global fetch to prevent real network calls.
|
|
// Return 404 for all requests so adapters get errors but don't crash.
|
|
fetchSpy = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({}),
|
|
});
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('syncAll returns an array of SyncResult', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll();
|
|
|
|
expect(Array.isArray(results)).toBe(true);
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('each SyncResult has the correct shape', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll();
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
it('returns one result per source', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const sources = db.listSources();
|
|
const results = await sync.syncAll();
|
|
|
|
expect(results.length).toBe(sources.length);
|
|
});
|
|
|
|
it('each result source matches a known source name', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const sources = db.listSources();
|
|
const sourceNames = sources.map(s => s.name);
|
|
const results = await sync.syncAll();
|
|
|
|
for (const result of results) {
|
|
expect(sourceNames).toContain(result.source);
|
|
}
|
|
});
|
|
|
|
it('with mocked 404 fetch, all sources report errors', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll();
|
|
|
|
// Sources with adapters that make HTTP calls should have errors
|
|
const sourcesWithErrors = results.filter(r => r.errors.length > 0);
|
|
expect(sourcesWithErrors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('no source throws — errors are captured gracefully', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
// This should not throw even though all fetches fail
|
|
const results = await sync.syncAll();
|
|
expect(results).toBeDefined();
|
|
});
|
|
|
|
it('added/updated/removed are non-negative', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll();
|
|
|
|
for (const result of results) {
|
|
expect(result.added).toBeGreaterThanOrEqual(0);
|
|
expect(result.updated).toBeGreaterThanOrEqual(0);
|
|
expect(result.removed).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Filtered sync ───────────────────────────────────────────────────
|
|
|
|
describe('MarketplaceSync — filtered sync', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
const ctx = createTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({}),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('syncAll with sources filter only syncs specified sources', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll({ sources: ['clawhub', 'skillsmp'] });
|
|
|
|
expect(results.length).toBe(2);
|
|
const names = results.map(r => r.source);
|
|
expect(names).toContain('clawhub');
|
|
expect(names).toContain('skillsmp');
|
|
});
|
|
|
|
it('syncAll with unknown source name returns empty results', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll({ sources: ['nonexistent-source'] });
|
|
|
|
expect(results.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── Individual adapter routing ──────────────────────────────────────
|
|
|
|
describe('MarketplaceSync — adapter routing', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
const ctx = createTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('GitHub adapter handles successful repo response', async () => {
|
|
// Mock a successful GitHub API response with one skill repo
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ([{
|
|
name: 'test-mcp-server',
|
|
full_name: 'anthropics/test-mcp-server',
|
|
description: 'A test MCP server',
|
|
html_url: 'https://github.com/anthropics/test-mcp-server',
|
|
clone_url: 'https://github.com/anthropics/test-mcp-server.git',
|
|
topics: ['mcp', 'mcp-server'],
|
|
stargazers_count: 42,
|
|
license: { spdx_id: 'MIT' },
|
|
homepage: null,
|
|
}]),
|
|
}));
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll({ sources: ['anthropics-skills'] });
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].source).toBe('anthropics-skills');
|
|
expect(results[0].added).toBeGreaterThanOrEqual(1);
|
|
expect(results[0].errors.length).toBe(0);
|
|
});
|
|
|
|
it('ClawHub adapter handles paginated API response', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
skills: [{
|
|
slug: 'test-skill',
|
|
name: 'Test Skill',
|
|
description: 'A test skill',
|
|
author: 'tester',
|
|
version: '1.0.0',
|
|
downloads: 100,
|
|
}],
|
|
}),
|
|
}));
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll({ sources: ['clawhub'] });
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].source).toBe('clawhub');
|
|
expect(results[0].added).toBe(1);
|
|
expect(results[0].errors.length).toBe(0);
|
|
});
|
|
|
|
it('LobeHub adapter handles plugin index response', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
plugins: [{
|
|
identifier: 'test-plugin',
|
|
name: 'Test Plugin',
|
|
description: 'A test plugin',
|
|
version: '1.0.0',
|
|
author: 'lobehub',
|
|
}],
|
|
}),
|
|
}));
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
// Find the lobehub source name in the seeded DB
|
|
const sources = db.listSources();
|
|
const lobeSrc = sources.find(s => s.url?.includes('lobehub'));
|
|
|
|
if (!lobeSrc) return; // Skip if no lobehub source in seed
|
|
|
|
const results = await sync.syncAll({ sources: [lobeSrc.name] });
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].added).toBeGreaterThanOrEqual(1);
|
|
expect(results[0].errors.length).toBe(0);
|
|
});
|
|
|
|
it('SkillsMP adapter handles rate limit gracefully', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: 'Too Many Requests',
|
|
json: async () => ({}),
|
|
}));
|
|
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll({ sources: ['skillsmp'] });
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].errors.length).toBeGreaterThan(0);
|
|
expect(results[0].errors[0]).toContain('rate limit');
|
|
});
|
|
});
|
|
|
|
// ── Empty DB sync ───────────────────────────────────────────────────
|
|
|
|
describe('MarketplaceSync — empty DB', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
const ctx = createEmptyTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({}),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('syncAll on empty DB returns empty array', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll();
|
|
|
|
expect(Array.isArray(results)).toBe(true);
|
|
expect(results.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── Sync endpoint response format ───────────────────────────────────
|
|
|
|
describe('MarketplaceSync — endpoint response aggregation', () => {
|
|
let db: MarketplaceDB;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
const ctx = createTempDb();
|
|
db = ctx.db;
|
|
tmpDir = ctx.tmpDir;
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
json: async () => ({}),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('results can be aggregated into the POST /api/marketplace/sync response format', async () => {
|
|
const sync = new MarketplaceSync(db);
|
|
const results = await sync.syncAll();
|
|
|
|
// Simulate the route handler aggregation
|
|
const sourcesChecked = results.length;
|
|
const packagesAdded = results.reduce((sum: number, r: SyncResult) => sum + r.added, 0);
|
|
const packagesUpdated = results.reduce((sum: number, r: SyncResult) => sum + r.updated, 0);
|
|
const errors = results.flatMap((r: SyncResult) => r.errors.map(e => `[${r.source}] ${e}`));
|
|
|
|
const responseBody = {
|
|
sourcesChecked,
|
|
packagesAdded,
|
|
packagesUpdated,
|
|
errors,
|
|
details: results,
|
|
};
|
|
|
|
expect(typeof responseBody.sourcesChecked).toBe('number');
|
|
expect(typeof responseBody.packagesAdded).toBe('number');
|
|
expect(typeof responseBody.packagesUpdated).toBe('number');
|
|
expect(Array.isArray(responseBody.errors)).toBe(true);
|
|
expect(Array.isArray(responseBody.details)).toBe(true);
|
|
expect(responseBody.sourcesChecked).toBeGreaterThan(0);
|
|
expect(responseBody.details.length).toBe(responseBody.sourcesChecked);
|
|
});
|
|
});
|