Files
waggle-os/packages/server/tests/local/marketplace-sources.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

460 lines
14 KiB
TypeScript

/**
* Marketplace Source Management — Tests
*
* Tests for user-defined source addition, listing, and deletion:
* - addSource inserts a custom source
* - listSourcesWithCounts returns package counts
* - deleteSource removes custom sources but not built-in ones
* - ensureIsCustomColumn is safe to call multiple times
* - Enhanced search with sort parameter
*/
import { describe, it, expect, beforeEach, afterEach } 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 '@waggle/marketplace';
// ── Helpers ──────────────────────────────────────────────────────────
function createEmptyTempDb(): { db: MarketplaceDB; tmpDir: string; dbPath: string } {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-mkt-src-'));
const dbPath = path.join(tmpDir, 'marketplace.db');
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,
is_custom BOOLEAN DEFAULT 0
);
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 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'))
);
`);
// Seed a built-in source (is_custom = 0)
raw.prepare(`
INSERT INTO sources (name, display_name, url, source_type, platform, total_packages, is_custom)
VALUES ('builtin-source', 'Built-in Source', 'https://example.com', 'marketplace', 'waggle', 5, 0)
`).run();
raw.close();
const db = new MarketplaceDB(dbPath);
return { db, tmpDir, dbPath };
}
// ── Source Management ────────────────────────────────────────────────
describe('MarketplaceDB -- Source Management', () => {
let db: MarketplaceDB;
let tmpDir: string;
beforeEach(() => {
const ctx = createEmptyTempDb();
db = ctx.db;
tmpDir = ctx.tmpDir;
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ── addSource ──────────────────────────────────────────────────────
it('addSource inserts a custom source and returns its ID', () => {
const id = db.addSource({
name: 'my-skills',
display_name: 'My Custom Skills',
url: 'https://github.com/myuser/my-skills',
source_type: 'community_repo',
});
expect(id).toBeGreaterThan(0);
const source = db.getSource(id);
expect(source).not.toBeNull();
expect(source!.name).toBe('my-skills');
expect(source!.display_name).toBe('My Custom Skills');
expect(source!.url).toBe('https://github.com/myuser/my-skills');
expect(source!.source_type).toBe('community_repo');
expect(source!.is_custom).toBeTruthy();
});
it('addSource sets is_custom to true', () => {
const id = db.addSource({
name: 'custom-repo',
display_name: 'Custom Repo',
url: 'https://github.com/user/repo',
source_type: 'community_repo',
});
const source = db.getSource(id);
expect(source!.is_custom).toBeTruthy();
});
// ── getSourceByName ────────────────────────────────────────────────
it('getSourceByName returns the correct source', () => {
const source = db.getSourceByName('builtin-source');
expect(source).not.toBeNull();
expect(source!.name).toBe('builtin-source');
expect(source!.display_name).toBe('Built-in Source');
});
it('getSourceByName returns null for non-existent name', () => {
const source = db.getSourceByName('nonexistent');
expect(source).toBeNull();
});
// ── listSources / listSourcesWithCounts ────────────────────────────
it('listSources returns all sources', () => {
db.addSource({
name: 'custom-1',
display_name: 'Custom 1',
url: 'https://example.com/1',
source_type: 'aggregator',
});
const sources = db.listSources();
expect(sources.length).toBe(2); // 1 built-in + 1 custom
});
it('listSourcesWithCounts returns package_count', () => {
// Add a custom source
const sourceId = db.addSource({
name: 'custom-with-packages',
display_name: 'Custom With Packages',
url: 'https://example.com/pkgs',
source_type: 'aggregator',
});
// Add a package to this source
db.upsertPackage({
name: 'test-pkg',
source_id: sourceId,
display_name: 'Test Package',
description: 'A test package',
author: 'tester',
package_type: 'skill',
waggle_install_type: 'skill',
waggle_install_path: 'skills/test.md',
category: 'general',
platforms: [],
dependencies: [],
packs: [],
});
const sources = db.listSourcesWithCounts();
const customSrc = sources.find(s => s.name === 'custom-with-packages');
expect(customSrc).toBeDefined();
expect(customSrc!.package_count).toBe(1);
});
// ── deleteSource ───────────────────────────────────────────────────
it('deleteSource removes a custom source', () => {
const id = db.addSource({
name: 'to-delete',
display_name: 'To Delete',
url: 'https://example.com/delete',
source_type: 'aggregator',
});
const deleted = db.deleteSource(id);
expect(deleted).toBe(true);
const source = db.getSource(id);
expect(source).toBeNull();
});
it('deleteSource removes packages belonging to the source', () => {
const sourceId = db.addSource({
name: 'to-delete-with-pkgs',
display_name: 'To Delete With Packages',
url: 'https://example.com/delete2',
source_type: 'aggregator',
});
// Add packages to this source
db.upsertPackage({
name: 'pkg-to-delete',
source_id: sourceId,
display_name: 'Package To Delete',
description: 'Will be deleted with source',
author: 'tester',
package_type: 'skill',
waggle_install_type: 'skill',
waggle_install_path: 'skills/delete.md',
category: 'general',
platforms: [],
dependencies: [],
packs: [],
});
expect(db.getPackageByName('pkg-to-delete')).not.toBeNull();
db.deleteSource(sourceId);
// Package should be gone too
expect(db.getPackageByName('pkg-to-delete')).toBeNull();
});
it('deleteSource refuses to delete built-in source', () => {
const builtIn = db.getSourceByName('builtin-source');
expect(builtIn).not.toBeNull();
const deleted = db.deleteSource(builtIn!.id);
expect(deleted).toBe(false);
// Source should still exist
const stillThere = db.getSource(builtIn!.id);
expect(stillThere).not.toBeNull();
});
it('deleteSource returns false for non-existent ID', () => {
const deleted = db.deleteSource(99999);
expect(deleted).toBe(false);
});
// ── ensureIsCustomColumn ───────────────────────────────────────────
it('ensureIsCustomColumn is idempotent (safe to call multiple times)', () => {
// Column already exists in our test schema
expect(() => db.ensureIsCustomColumn()).not.toThrow();
expect(() => db.ensureIsCustomColumn()).not.toThrow();
});
});
// ── Enhanced Search with Sort ────────────────────────────────────────
describe('MarketplaceDB -- Enhanced Search', () => {
let db: MarketplaceDB;
let tmpDir: string;
beforeEach(() => {
const ctx = createEmptyTempDb();
db = ctx.db;
tmpDir = ctx.tmpDir;
// Seed some packages with different attributes
db.upsertPackage({
name: 'alpha-skill',
source_id: 1,
display_name: 'Alpha Skill',
description: 'First skill alphabetically',
author: 'tester',
package_type: 'skill',
waggle_install_type: 'skill',
waggle_install_path: 'skills/alpha.md',
category: 'coding',
downloads: 100,
stars: 5,
platforms: [],
dependencies: [],
packs: [],
});
db.upsertPackage({
name: 'zeta-skill',
source_id: 1,
display_name: 'Zeta Skill',
description: 'Last skill alphabetically',
author: 'tester',
package_type: 'skill',
waggle_install_type: 'skill',
waggle_install_path: 'skills/zeta.md',
category: 'security',
downloads: 500,
stars: 20,
platforms: [],
dependencies: [],
packs: [],
});
db.upsertPackage({
name: 'beta-plugin',
source_id: 1,
display_name: 'Beta Plugin',
description: 'A plugin for communication and Slack',
author: 'tester',
package_type: 'plugin',
waggle_install_type: 'plugin',
waggle_install_path: 'plugins/beta/',
category: 'communication',
downloads: 250,
stars: 10,
platforms: [],
dependencies: [],
packs: [],
});
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('search returns installedCount in results', () => {
const results = db.search({ limit: 10 });
expect(results).toHaveProperty('installedCount');
expect(typeof results.installedCount).toBe('number');
expect(results.installedCount).toBe(0); // nothing installed
});
it('sort=popular orders by downloads DESC', () => {
const results = db.search({ sort: 'popular', limit: 10 });
expect(results.packages.length).toBe(3);
expect(results.packages[0].name).toBe('zeta-skill'); // 500 downloads
expect(results.packages[1].name).toBe('beta-plugin'); // 250 downloads
expect(results.packages[2].name).toBe('alpha-skill'); // 100 downloads
});
it('sort=name orders alphabetically by display_name', () => {
const results = db.search({ sort: 'name', limit: 10 });
expect(results.packages.length).toBe(3);
expect(results.packages[0].name).toBe('alpha-skill');
expect(results.packages[1].name).toBe('beta-plugin');
expect(results.packages[2].name).toBe('zeta-skill');
});
it('category filter works', () => {
const results = db.search({ category: 'coding', limit: 10 });
expect(results.packages.length).toBe(1);
expect(results.packages[0].name).toBe('alpha-skill');
});
it('source filter works with source name', () => {
const results = db.search({ source: 'builtin-source', limit: 10 });
expect(results.packages.length).toBe(3); // all packages belong to source_id 1
});
it('facets include categories with counts', () => {
const results = db.search({ limit: 10 });
expect(results.facets.categories).toBeDefined();
expect(results.facets.categories['coding']).toBe(1);
expect(results.facets.categories['security']).toBe(1);
expect(results.facets.categories['communication']).toBe(1);
});
it('type filter works', () => {
const results = db.search({ type: 'plugin', limit: 10 });
expect(results.packages.length).toBe(1);
expect(results.packages[0].waggle_install_type).toBe('plugin');
});
it('getInstalledCount reflects actual installations', () => {
expect(db.getInstalledCount()).toBe(0);
});
});