Platform Acceleration Phase 1: - @bytelyst/sync package: Offline-first sync engine with conflict resolution - Storage adapters: LocalStorage, InMemory, MMKV - Deduplication, retry with backoff, auto-flush on reconnect - 12 comprehensive tests - @bytelyst/dashboard-components package: Shared React components - ErrorPage, NotFoundPage, LoadingSpinner, LoadingSkeleton, EmptyState, PageHeader - Theme-aware with CSS custom properties A/B Testing Framework (Complete): - Admin UI at /ops/ab-testing with experiments list, variant performance, AI suggestions - Sidebar navigation with Beaker icon - 40 tests passing in ab-testing module All 909 platform-service tests pass.
276 lines
8.3 KiB
TypeScript
276 lines
8.3 KiB
TypeScript
/**
|
|
* Sync Engine Tests
|
|
*
|
|
* @module @bytelyst/sync/engine.test
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { createSyncEngine, InMemoryAdapter } from './index.js';
|
|
import type { ApiClient, ApiResult } from '@bytelyst/api-client';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Mock API Client
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function createMockApiClient(): ApiClient & {
|
|
getRequests: () => { path: string; options?: RequestInit }[];
|
|
} {
|
|
const requests: { path: string; options?: RequestInit }[] = [];
|
|
|
|
return {
|
|
fetch: async <T>(path: string, options?: RequestInit): Promise<T> => {
|
|
requests.push({ path, options });
|
|
return { items: [] } as unknown as T;
|
|
},
|
|
safeFetch: async <T>(path: string, options?: RequestInit): Promise<ApiResult<T>> => {
|
|
requests.push({ path, options });
|
|
return { data: { items: [] } as unknown as T, error: null };
|
|
},
|
|
getRequests: () => requests,
|
|
};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Sync Engine', () => {
|
|
let storage: InMemoryAdapter;
|
|
let apiClient: ReturnType<typeof createMockApiClient>;
|
|
|
|
beforeEach(() => {
|
|
storage = new InMemoryAdapter();
|
|
apiClient = createMockApiClient();
|
|
});
|
|
|
|
describe('createSyncEngine', () => {
|
|
it('creates a sync engine with default config', () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
expect(engine).toBeDefined();
|
|
expect(engine.push).toBeDefined();
|
|
expect(engine.pull).toBeDefined();
|
|
expect(engine.fullSync).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('push', () => {
|
|
it('adds item to queue', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
await engine.push('tasks', { title: 'Test Task' });
|
|
|
|
const status = engine.getStatus();
|
|
expect(status.status).toBe('idle');
|
|
});
|
|
|
|
it('deduplicates items for same entity', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
await engine.push('tasks', { id: '1', title: 'Task 1' });
|
|
await engine.push('tasks', { id: '1', title: 'Task 1 Updated' });
|
|
|
|
// Queue should have 1 item (deduplicated)
|
|
const result = await engine.fullSync();
|
|
expect(result.pushed).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('creates delete operation', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
await engine.delete('tasks', 'task-123');
|
|
|
|
const requests = apiClient.getRequests();
|
|
// Delete is queued but not flushed until sync
|
|
expect(requests.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('fullSync', () => {
|
|
it('pushes queued items', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
await engine.push('tasks', { title: 'Test Task' });
|
|
const result = await engine.fullSync();
|
|
|
|
expect(result.pushed).toBe(1);
|
|
|
|
const requests = apiClient.getRequests();
|
|
expect(requests).toHaveLength(2); // Pull + Push
|
|
});
|
|
|
|
it('pulls remote changes', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
const result = await engine.fullSync();
|
|
expect(result.pulled).toBe(0); // Mock returns empty
|
|
});
|
|
});
|
|
|
|
describe('status and monitoring', () => {
|
|
it('returns initial status', () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
const status = engine.getStatus();
|
|
expect(status.status).toBe('idle');
|
|
});
|
|
|
|
it('notifies status changes', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
const statuses: string[] = [];
|
|
engine.onStatusChange(status => {
|
|
statuses.push(status.status);
|
|
});
|
|
|
|
await engine.push('tasks', { title: 'Test' });
|
|
|
|
// Status changes during push
|
|
expect(statuses.length).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('clearQueue', () => {
|
|
it('removes all queued items', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
await engine.push('tasks', { title: 'Task 1' });
|
|
await engine.push('tasks', { title: 'Task 2' });
|
|
await engine.clearQueue();
|
|
|
|
const result = await engine.fullSync();
|
|
expect(result.pushed).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('reprocessFailed', () => {
|
|
it('resets retry count on failed items', async () => {
|
|
const engine = createSyncEngine({
|
|
productId: 'test',
|
|
entities: {
|
|
tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
|
|
},
|
|
storage,
|
|
apiClient,
|
|
});
|
|
|
|
await engine.push('tasks', { title: 'Test' });
|
|
await engine.reprocessFailed();
|
|
|
|
// Items should be reprocessed
|
|
const result = await engine.fullSync();
|
|
expect(result.pushed).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Storage Adapters', () => {
|
|
describe('InMemoryAdapter', () => {
|
|
it('stores and retrieves items', () => {
|
|
const storage = new InMemoryAdapter();
|
|
storage.setItem('key1', { value: 123 });
|
|
|
|
const retrieved = storage.getItem<{ value: number }>('key1');
|
|
expect(retrieved).toEqual({ value: 123 });
|
|
});
|
|
|
|
it('returns null for missing keys', () => {
|
|
const storage = new InMemoryAdapter();
|
|
const retrieved = storage.getItem('missing');
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
|
|
it('lists all keys', () => {
|
|
const storage = new InMemoryAdapter();
|
|
storage.setItem('key1', 'value1');
|
|
storage.setItem('key2', 'value2');
|
|
|
|
const keys = storage.keys();
|
|
expect(keys).toContain('key1');
|
|
expect(keys).toContain('key2');
|
|
});
|
|
|
|
it('removes items', () => {
|
|
const storage = new InMemoryAdapter();
|
|
storage.setItem('key1', 'value1');
|
|
storage.removeItem('key1');
|
|
|
|
expect(storage.getItem('key1')).toBeNull();
|
|
});
|
|
|
|
it('clears all items', () => {
|
|
const storage = new InMemoryAdapter();
|
|
storage.setItem('key1', 'value1');
|
|
storage.setItem('key2', 'value2');
|
|
storage.clear();
|
|
|
|
expect(storage.keys()).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|