- Updated all package.json name fields and dependency references - Updated all TypeScript/JavaScript imports - Updated .woodpecker/publish.yml filters and registry paths - Updated tools/install.sh scope default - Updated .npmrc registry paths (worktree + host) - Enhanced update-checker.ts with checkForAllUpdates() multi-package support - Updated CLI update command to show table of all packages - Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand - Marked checkForUpdate() with @deprecated JSDoc Closes #391
299 lines
9.6 KiB
TypeScript
299 lines
9.6 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import type { StorageAdapter } from '@mosaicstack/storage';
|
|
import { KeywordAdapter } from './keyword.js';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* In-memory mock StorageAdapter */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function createMockStorage(): StorageAdapter {
|
|
const collections = new Map<string, Map<string, Record<string, unknown>>>();
|
|
let idCounter = 0;
|
|
|
|
function getCollection(name: string): Map<string, Record<string, unknown>> {
|
|
if (!collections.has(name)) collections.set(name, new Map());
|
|
return collections.get(name)!;
|
|
}
|
|
|
|
const adapter: StorageAdapter = {
|
|
name: 'mock',
|
|
|
|
async create<T extends Record<string, unknown>>(
|
|
collection: string,
|
|
data: T,
|
|
): Promise<T & { id: string }> {
|
|
const id = String(++idCounter);
|
|
const record = { ...data, id };
|
|
getCollection(collection).set(id, record);
|
|
return record as T & { id: string };
|
|
},
|
|
|
|
async read<T extends Record<string, unknown>>(
|
|
collection: string,
|
|
id: string,
|
|
): Promise<T | null> {
|
|
const record = getCollection(collection).get(id);
|
|
return (record as T) ?? null;
|
|
},
|
|
|
|
async update(collection: string, id: string, data: Record<string, unknown>): Promise<boolean> {
|
|
const col = getCollection(collection);
|
|
const existing = col.get(id);
|
|
if (!existing) return false;
|
|
col.set(id, { ...existing, ...data });
|
|
return true;
|
|
},
|
|
|
|
async delete(collection: string, id: string): Promise<boolean> {
|
|
return getCollection(collection).delete(id);
|
|
},
|
|
|
|
async find<T extends Record<string, unknown>>(
|
|
collection: string,
|
|
filter?: Record<string, unknown>,
|
|
): Promise<T[]> {
|
|
const col = getCollection(collection);
|
|
const results: T[] = [];
|
|
for (const record of col.values()) {
|
|
if (filter && !matchesFilter(record, filter)) continue;
|
|
results.push(record as T);
|
|
}
|
|
return results;
|
|
},
|
|
|
|
async findOne<T extends Record<string, unknown>>(
|
|
collection: string,
|
|
filter: Record<string, unknown>,
|
|
): Promise<T | null> {
|
|
const col = getCollection(collection);
|
|
for (const record of col.values()) {
|
|
if (matchesFilter(record, filter)) return record as T;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
|
|
const rows = await adapter.find(collection, filter);
|
|
return rows.length;
|
|
},
|
|
|
|
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
|
|
return fn(adapter);
|
|
},
|
|
|
|
async migrate(): Promise<void> {},
|
|
async close(): Promise<void> {},
|
|
};
|
|
|
|
return adapter;
|
|
}
|
|
|
|
function matchesFilter(record: Record<string, unknown>, filter: Record<string, unknown>): boolean {
|
|
for (const [key, value] of Object.entries(filter)) {
|
|
if (record[key] !== value) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Tests */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
describe('KeywordAdapter', () => {
|
|
let adapter: KeywordAdapter;
|
|
|
|
beforeEach(() => {
|
|
adapter = new KeywordAdapter({ type: 'keyword', storage: createMockStorage() });
|
|
});
|
|
|
|
/* ---- Preferences ---- */
|
|
|
|
describe('preferences', () => {
|
|
it('should set and get a preference', async () => {
|
|
await adapter.setPreference('u1', 'theme', 'dark');
|
|
const value = await adapter.getPreference('u1', 'theme');
|
|
expect(value).toBe('dark');
|
|
});
|
|
|
|
it('should return null for missing preference', async () => {
|
|
const value = await adapter.getPreference('u1', 'nonexistent');
|
|
expect(value).toBeNull();
|
|
});
|
|
|
|
it('should upsert an existing preference', async () => {
|
|
await adapter.setPreference('u1', 'theme', 'dark');
|
|
await adapter.setPreference('u1', 'theme', 'light');
|
|
const value = await adapter.getPreference('u1', 'theme');
|
|
expect(value).toBe('light');
|
|
});
|
|
|
|
it('should delete a preference', async () => {
|
|
await adapter.setPreference('u1', 'theme', 'dark');
|
|
const deleted = await adapter.deletePreference('u1', 'theme');
|
|
expect(deleted).toBe(true);
|
|
const value = await adapter.getPreference('u1', 'theme');
|
|
expect(value).toBeNull();
|
|
});
|
|
|
|
it('should return false when deleting nonexistent preference', async () => {
|
|
const deleted = await adapter.deletePreference('u1', 'nope');
|
|
expect(deleted).toBe(false);
|
|
});
|
|
|
|
it('should list preferences by userId', async () => {
|
|
await adapter.setPreference('u1', 'theme', 'dark', 'appearance');
|
|
await adapter.setPreference('u1', 'lang', 'en', 'locale');
|
|
await adapter.setPreference('u2', 'theme', 'light', 'appearance');
|
|
|
|
const prefs = await adapter.listPreferences('u1');
|
|
expect(prefs).toHaveLength(2);
|
|
expect(prefs.map((p) => p.key).sort()).toEqual(['lang', 'theme']);
|
|
});
|
|
|
|
it('should filter preferences by category', async () => {
|
|
await adapter.setPreference('u1', 'theme', 'dark', 'appearance');
|
|
await adapter.setPreference('u1', 'lang', 'en', 'locale');
|
|
|
|
const prefs = await adapter.listPreferences('u1', 'appearance');
|
|
expect(prefs).toHaveLength(1);
|
|
expect(prefs[0]!.key).toBe('theme');
|
|
});
|
|
});
|
|
|
|
/* ---- Insights ---- */
|
|
|
|
describe('insights', () => {
|
|
it('should store and retrieve an insight', async () => {
|
|
const insight = await adapter.storeInsight({
|
|
userId: 'u1',
|
|
content: 'TypeScript is great for type safety',
|
|
source: 'chat',
|
|
category: 'technical',
|
|
relevanceScore: 0.9,
|
|
});
|
|
|
|
expect(insight.id).toBeDefined();
|
|
expect(insight.content).toBe('TypeScript is great for type safety');
|
|
|
|
const fetched = await adapter.getInsight(insight.id);
|
|
expect(fetched).not.toBeNull();
|
|
expect(fetched!.content).toBe('TypeScript is great for type safety');
|
|
});
|
|
|
|
it('should return null for missing insight', async () => {
|
|
const result = await adapter.getInsight('nonexistent');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should delete an insight', async () => {
|
|
const insight = await adapter.storeInsight({
|
|
userId: 'u1',
|
|
content: 'test',
|
|
source: 'chat',
|
|
category: 'general',
|
|
relevanceScore: 0.5,
|
|
});
|
|
|
|
const deleted = await adapter.deleteInsight(insight.id);
|
|
expect(deleted).toBe(true);
|
|
|
|
const fetched = await adapter.getInsight(insight.id);
|
|
expect(fetched).toBeNull();
|
|
});
|
|
});
|
|
|
|
/* ---- Keyword Search ---- */
|
|
|
|
describe('searchInsights', () => {
|
|
beforeEach(async () => {
|
|
await adapter.storeInsight({
|
|
userId: 'u1',
|
|
content: 'TypeScript provides excellent type safety for JavaScript projects',
|
|
source: 'chat',
|
|
category: 'technical',
|
|
relevanceScore: 0.9,
|
|
});
|
|
await adapter.storeInsight({
|
|
userId: 'u1',
|
|
content: 'React hooks simplify state management in components',
|
|
source: 'chat',
|
|
category: 'technical',
|
|
relevanceScore: 0.8,
|
|
});
|
|
await adapter.storeInsight({
|
|
userId: 'u1',
|
|
content: 'TypeScript and React work great together for type safe components',
|
|
source: 'chat',
|
|
category: 'technical',
|
|
relevanceScore: 0.85,
|
|
});
|
|
await adapter.storeInsight({
|
|
userId: 'u2',
|
|
content: 'TypeScript is popular',
|
|
source: 'chat',
|
|
category: 'general',
|
|
relevanceScore: 0.5,
|
|
});
|
|
});
|
|
|
|
it('should find insights by exact keyword', async () => {
|
|
const results = await adapter.searchInsights('u1', 'hooks');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.content).toContain('hooks');
|
|
});
|
|
|
|
it('should be case-insensitive', async () => {
|
|
const results = await adapter.searchInsights('u1', 'TYPESCRIPT');
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
for (const r of results) {
|
|
expect(r.content.toLowerCase()).toContain('typescript');
|
|
}
|
|
});
|
|
|
|
it('should rank multi-word matches higher', async () => {
|
|
const results = await adapter.searchInsights('u1', 'TypeScript React');
|
|
// The insight mentioning both "TypeScript" and "React" should rank first (score=2)
|
|
expect(results[0]!.score).toBe(2);
|
|
expect(results[0]!.content).toContain('TypeScript');
|
|
expect(results[0]!.content).toContain('React');
|
|
});
|
|
|
|
it('should return empty for no matches', async () => {
|
|
const results = await adapter.searchInsights('u1', 'python django');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should filter by userId', async () => {
|
|
const results = await adapter.searchInsights('u2', 'TypeScript');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.content).toBe('TypeScript is popular');
|
|
});
|
|
|
|
it('should respect limit option', async () => {
|
|
const results = await adapter.searchInsights('u1', 'TypeScript', { limit: 1 });
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
|
|
it('should return empty for empty query', async () => {
|
|
const results = await adapter.searchInsights('u1', ' ');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
/* ---- Lifecycle ---- */
|
|
|
|
describe('lifecycle', () => {
|
|
it('should have name "keyword"', () => {
|
|
expect(adapter.name).toBe('keyword');
|
|
});
|
|
|
|
it('should have null embedder', () => {
|
|
expect(adapter.embedder).toBeNull();
|
|
});
|
|
|
|
it('should close without error', async () => {
|
|
await expect(adapter.close()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|