mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
948 lines
27 KiB
TypeScript
948 lines
27 KiB
TypeScript
/**
|
|
* CloudSyncAgent Unit Tests
|
|
*
|
|
* Tests for sync agent orchestration including:
|
|
* - Initialization with multiple data sources
|
|
* - Full and incremental sync operations
|
|
* - Progress tracking and error handling
|
|
* - Transaction management (commit/rollback)
|
|
* - Source priority ordering
|
|
* - Dry run mode
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Mock } from 'vitest';
|
|
|
|
// Mock dependencies before importing
|
|
vi.mock('../../../src/sync/readers/sqlite-reader.js', () => ({
|
|
createSQLiteReader: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/sync/readers/json-reader.js', () => ({
|
|
createJSONReader: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/sync/cloud/tunnel-manager.js', () => ({
|
|
createConnectionManager: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/sync/cloud/postgres-writer.js', () => ({
|
|
createPostgresWriter: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('uuid', () => ({
|
|
v4: vi.fn(() => 'test-sync-id-12345'),
|
|
}));
|
|
|
|
import {
|
|
CloudSyncAgent,
|
|
createSyncAgent,
|
|
syncToCloud,
|
|
syncIncrementalToCloud,
|
|
type SyncAgentConfig,
|
|
} from '../../../src/sync/sync-agent.js';
|
|
import { createSQLiteReader } from '../../../src/sync/readers/sqlite-reader.js';
|
|
import { createJSONReader } from '../../../src/sync/readers/json-reader.js';
|
|
import { createConnectionManager } from '../../../src/sync/cloud/tunnel-manager.js';
|
|
import { createPostgresWriter } from '../../../src/sync/cloud/postgres-writer.js';
|
|
import type { SyncSource, CloudWriter, TunnelConnection } from '../../../src/sync/interfaces.js';
|
|
|
|
// Helper to create mock reader
|
|
function createMockReader(name: string, type: 'sqlite' | 'json', records: unknown[] = []) {
|
|
return {
|
|
name,
|
|
type,
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
readAll: vi.fn().mockResolvedValue(records),
|
|
readChanged: vi.fn().mockResolvedValue(records),
|
|
count: vi.fn().mockResolvedValue(records.length),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
// Helper to create mock writer
|
|
function createMockWriter(): CloudWriter & { _mocks: Record<string, Mock> } {
|
|
const mocks = {
|
|
connect: vi.fn().mockResolvedValue(undefined),
|
|
beginTransaction: vi.fn().mockResolvedValue(undefined),
|
|
commit: vi.fn().mockResolvedValue(undefined),
|
|
rollback: vi.fn().mockResolvedValue(undefined),
|
|
upsert: vi.fn().mockResolvedValue(5),
|
|
execute: vi.fn().mockResolvedValue(undefined),
|
|
query: vi.fn().mockResolvedValue([{ count: 10 }]),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
return {
|
|
...mocks,
|
|
_mocks: mocks,
|
|
};
|
|
}
|
|
|
|
// Helper to create mock tunnel manager
|
|
function createMockTunnelManager() {
|
|
const connection: TunnelConnection = {
|
|
host: 'localhost',
|
|
port: 15432,
|
|
pid: 12345,
|
|
startedAt: new Date(),
|
|
};
|
|
|
|
return {
|
|
start: vi.fn().mockResolvedValue(connection),
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
isActive: vi.fn().mockReturnValue(true),
|
|
getConnection: vi.fn().mockReturnValue(connection),
|
|
};
|
|
}
|
|
|
|
describe('CloudSyncAgent', () => {
|
|
let mockSqliteReader: ReturnType<typeof createMockReader>;
|
|
let mockJsonReader: ReturnType<typeof createMockReader>;
|
|
let mockWriter: ReturnType<typeof createMockWriter>;
|
|
let mockTunnelManager: ReturnType<typeof createMockTunnelManager>;
|
|
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Create mock instances
|
|
mockSqliteReader = createMockReader('v3-qe-patterns', 'sqlite', [
|
|
{ id: '1', pattern: 'test-pattern', confidence: 0.9 },
|
|
{ id: '2', pattern: 'another-pattern', confidence: 0.8 },
|
|
]);
|
|
|
|
mockJsonReader = createMockReader('claude-flow-memory', 'json', [
|
|
{ key: 'memory-1', value: { data: 'test' } },
|
|
]);
|
|
|
|
mockWriter = createMockWriter();
|
|
mockTunnelManager = createMockTunnelManager();
|
|
|
|
// Setup mock factory functions
|
|
(createSQLiteReader as Mock).mockReturnValue(mockSqliteReader);
|
|
(createJSONReader as Mock).mockReturnValue(mockJsonReader);
|
|
(createConnectionManager as Mock).mockReturnValue(mockTunnelManager);
|
|
(createPostgresWriter as Mock).mockReturnValue(mockWriter);
|
|
|
|
// Suppress console output in tests
|
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleLogSpy.mockRestore();
|
|
consoleWarnSpy.mockRestore();
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create agent with default config', () => {
|
|
const agent = new CloudSyncAgent();
|
|
expect(agent).toBeDefined();
|
|
});
|
|
|
|
it('should merge custom config with defaults', () => {
|
|
const customConfig: Partial<SyncAgentConfig> = {
|
|
environment: 'test-env',
|
|
verbose: true,
|
|
};
|
|
|
|
const agent = new CloudSyncAgent(customConfig);
|
|
expect(agent).toBeDefined();
|
|
});
|
|
|
|
it('should accept progress and error callbacks', () => {
|
|
const onProgress = vi.fn();
|
|
const onError = vi.fn();
|
|
|
|
const agent = new CloudSyncAgent({
|
|
onProgress,
|
|
onError,
|
|
verbose: true,
|
|
});
|
|
|
|
expect(agent).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('initialize', () => {
|
|
it('should initialize readers for enabled sources', async () => {
|
|
const config: Partial<SyncAgentConfig> = {
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-sqlite',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
verbose: true,
|
|
};
|
|
|
|
const agent = new CloudSyncAgent(config);
|
|
await agent.initialize();
|
|
|
|
expect(createSQLiteReader).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ name: 'test-sqlite' }),
|
|
})
|
|
);
|
|
expect(mockSqliteReader.initialize).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip disabled sources', async () => {
|
|
const config: Partial<SyncAgentConfig> = {
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'disabled-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: false,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const agent = new CloudSyncAgent(config);
|
|
await agent.initialize();
|
|
|
|
expect(createSQLiteReader).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle reader initialization failures gracefully', async () => {
|
|
mockSqliteReader.initialize.mockRejectedValue(new Error('DB not found'));
|
|
|
|
const config: Partial<SyncAgentConfig> = {
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'failing-source',
|
|
type: 'sqlite',
|
|
path: 'nonexistent.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
verbose: true,
|
|
};
|
|
|
|
const agent = new CloudSyncAgent(config);
|
|
|
|
// Should not throw
|
|
await expect(agent.initialize()).resolves.not.toThrow();
|
|
});
|
|
|
|
it('should create JSON readers for json sources', async () => {
|
|
const config: Partial<SyncAgentConfig> = {
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-json',
|
|
type: 'json',
|
|
path: 'store.json',
|
|
targetTable: 'json_table',
|
|
priority: 'medium',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const agent = new CloudSyncAgent(config);
|
|
await agent.initialize();
|
|
|
|
expect(createJSONReader).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('syncAll', () => {
|
|
let agent: CloudSyncAgent;
|
|
let testSources: SyncSource[];
|
|
|
|
beforeEach(async () => {
|
|
testSources = [
|
|
{
|
|
name: 'high-priority',
|
|
type: 'sqlite',
|
|
path: 'test1.db',
|
|
targetTable: 'table1',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: 'low-priority',
|
|
type: 'sqlite',
|
|
path: 'test2.db',
|
|
targetTable: 'table2',
|
|
priority: 'low',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: 'medium-priority',
|
|
type: 'json',
|
|
path: 'test.json',
|
|
targetTable: 'table3',
|
|
priority: 'medium',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
];
|
|
|
|
agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: testSources,
|
|
},
|
|
verbose: true,
|
|
});
|
|
|
|
await agent.initialize();
|
|
});
|
|
|
|
it('should sync all enabled sources to cloud', async () => {
|
|
const report = await agent.syncAll();
|
|
|
|
expect(report.syncId).toBe('test-sync-id-12345');
|
|
expect(report.status).toBe('completed');
|
|
expect(report.results.length).toBe(3);
|
|
});
|
|
|
|
it('should sync sources in priority order (high, medium, low)', async () => {
|
|
const syncOrder: string[] = [];
|
|
mockWriter._mocks.upsert.mockImplementation(async (table: string) => {
|
|
syncOrder.push(table);
|
|
return 5;
|
|
});
|
|
|
|
await agent.syncAll();
|
|
|
|
// Priority order: high -> medium -> low
|
|
expect(syncOrder[0]).toBe('table1'); // high
|
|
expect(syncOrder[1]).toBe('table3'); // medium
|
|
expect(syncOrder[2]).toBe('table2'); // low
|
|
});
|
|
|
|
it('should call progress callback during sync', async () => {
|
|
const onProgress = vi.fn();
|
|
const agentWithProgress = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: testSources,
|
|
},
|
|
onProgress,
|
|
verbose: true,
|
|
});
|
|
|
|
await agentWithProgress.initialize();
|
|
await agentWithProgress.syncAll();
|
|
|
|
expect(onProgress).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle transaction commit/rollback correctly', async () => {
|
|
await agent.syncAll();
|
|
|
|
// syncSource() delegates batching to postgres-writer (no outer transaction)
|
|
expect(mockWriter._mocks.beginTransaction).not.toHaveBeenCalled();
|
|
expect(mockWriter._mocks.commit).not.toHaveBeenCalled();
|
|
expect(mockWriter._mocks.rollback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should mark report partial on upsert failure', async () => {
|
|
mockWriter._mocks.upsert.mockRejectedValueOnce(new Error('Upsert failed'));
|
|
|
|
const report = await agent.syncAll();
|
|
|
|
// syncSource catches the error — no transaction rollback at this layer
|
|
expect(mockWriter._mocks.rollback).not.toHaveBeenCalled();
|
|
expect(report.status).toBe('partial');
|
|
});
|
|
|
|
it('should track total records synced', async () => {
|
|
mockWriter._mocks.upsert.mockResolvedValue(10);
|
|
|
|
const report = await agent.syncAll();
|
|
|
|
expect(report.totalRecordsSynced).toBe(30); // 3 sources * 10 records
|
|
});
|
|
|
|
it('should handle connection failure', async () => {
|
|
mockWriter._mocks.connect.mockRejectedValue(new Error('Connection refused'));
|
|
|
|
const report = await agent.syncAll();
|
|
|
|
expect(report.status).toBe('failed');
|
|
expect(report.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should calculate duration correctly', async () => {
|
|
const report = await agent.syncAll();
|
|
|
|
expect(report.totalDurationMs).toBeGreaterThanOrEqual(0);
|
|
expect(report.completedAt).toBeDefined();
|
|
expect(report.completedAt!.getTime()).toBeGreaterThanOrEqual(report.startedAt.getTime());
|
|
});
|
|
});
|
|
|
|
describe('syncIncremental', () => {
|
|
let agent: CloudSyncAgent;
|
|
|
|
beforeEach(async () => {
|
|
agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'incremental',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'incremental-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'incremental',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
verbose: true,
|
|
});
|
|
|
|
await agent.initialize();
|
|
});
|
|
|
|
it('should use readChanged instead of readAll', async () => {
|
|
const since = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
|
|
await agent.syncIncremental(since);
|
|
|
|
expect(mockSqliteReader.readChanged).toHaveBeenCalledWith(since);
|
|
});
|
|
|
|
it('should default to 24 hours ago if no since date provided', async () => {
|
|
const beforeSync = Date.now();
|
|
await agent.syncIncremental();
|
|
|
|
const callArg = mockSqliteReader.readChanged.mock.calls[0][0] as Date;
|
|
const hoursDiff = (beforeSync - callArg.getTime()) / (1000 * 60 * 60);
|
|
|
|
expect(hoursDiff).toBeCloseTo(24, 0);
|
|
});
|
|
|
|
it('should skip sources with mode "full"', async () => {
|
|
const agentWithFullSource = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'incremental',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'full-only',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await agentWithFullSource.initialize();
|
|
const report = await agentWithFullSource.syncIncremental();
|
|
|
|
// Should not sync full-mode sources in incremental sync
|
|
expect(report.results.length).toBe(0);
|
|
});
|
|
|
|
it('should handle empty incremental results', async () => {
|
|
mockSqliteReader.readChanged.mockResolvedValue([]);
|
|
|
|
const report = await agent.syncIncremental();
|
|
|
|
expect(report.status).toBe('completed');
|
|
expect(report.totalRecordsSynced).toBe(0);
|
|
});
|
|
|
|
it('should set report mode to incremental', async () => {
|
|
const report = await agent.syncIncremental();
|
|
|
|
expect(report.mode).toBe('incremental');
|
|
});
|
|
});
|
|
|
|
describe('syncSource', () => {
|
|
let agent: CloudSyncAgent;
|
|
|
|
beforeEach(async () => {
|
|
agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
verbose: true,
|
|
});
|
|
|
|
await agent.initialize();
|
|
});
|
|
|
|
it('should return success for empty records', async () => {
|
|
mockSqliteReader.readAll.mockResolvedValue([]);
|
|
|
|
const source: SyncSource = {
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
};
|
|
|
|
const result = await agent.syncSource(source);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.recordsSynced).toBe(0);
|
|
});
|
|
|
|
it('should call error callback on failure', async () => {
|
|
const onError = vi.fn();
|
|
const agentWithError = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
onError,
|
|
verbose: true,
|
|
});
|
|
|
|
await agentWithError.initialize();
|
|
|
|
// Connect first
|
|
mockWriter._mocks.connect.mockResolvedValue(undefined);
|
|
mockSqliteReader.readAll.mockRejectedValue(new Error('Read error'));
|
|
|
|
const source: SyncSource = {
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
};
|
|
|
|
const result = await agentWithError.syncSource(source);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Read error');
|
|
expect(onError).toHaveBeenCalledWith(expect.any(Error), 'test-source');
|
|
});
|
|
|
|
it('should throw error for missing reader', async () => {
|
|
const source: SyncSource = {
|
|
name: 'nonexistent-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
};
|
|
|
|
const result = await agent.syncSource(source);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Reader not found');
|
|
});
|
|
});
|
|
|
|
describe('dry run mode', () => {
|
|
it('should not write to database in dry run mode', async () => {
|
|
const agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
dryRun: true,
|
|
},
|
|
verbose: true,
|
|
});
|
|
|
|
await agent.initialize();
|
|
const report = await agent.syncAll();
|
|
|
|
expect(mockWriter._mocks.upsert).not.toHaveBeenCalled();
|
|
expect(report.status).toBe('completed');
|
|
expect(report.totalRecordsSynced).toBe(2); // Records count from mock
|
|
});
|
|
});
|
|
|
|
describe('getStatus', () => {
|
|
let agent: CloudSyncAgent;
|
|
|
|
beforeEach(async () => {
|
|
agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await agent.initialize();
|
|
});
|
|
|
|
it('should return status for all sources', async () => {
|
|
mockSqliteReader.count.mockResolvedValue(50);
|
|
|
|
const status = await agent.getStatus();
|
|
|
|
expect(status.sources.length).toBeGreaterThan(0);
|
|
expect(status.sources[0].recordCount).toBe(50);
|
|
});
|
|
|
|
it('should handle uninitialized readers', async () => {
|
|
// Create agent without initializing
|
|
const uninitAgent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'uninit-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const status = await uninitAgent.getStatus();
|
|
|
|
expect(status.sources[0].error).toBe('Reader not initialized');
|
|
});
|
|
});
|
|
|
|
describe('verify', () => {
|
|
let agent: CloudSyncAgent;
|
|
|
|
beforeEach(async () => {
|
|
agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await agent.initialize();
|
|
});
|
|
|
|
it('should compare local and cloud counts', async () => {
|
|
mockSqliteReader.count.mockResolvedValue(10);
|
|
mockWriter._mocks.query.mockResolvedValue([{ count: 10 }]);
|
|
|
|
// Connect first by running sync
|
|
await agent.syncAll();
|
|
|
|
const result = await agent.verify();
|
|
|
|
// When writer is null after disconnect, cloudCount is 0
|
|
// Let's check verifyResult covers result
|
|
expect(result.results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should detect count mismatches', async () => {
|
|
mockSqliteReader.count.mockResolvedValue(15);
|
|
mockWriter._mocks.query.mockResolvedValue([{ count: 10 }]);
|
|
|
|
await agent.syncAll();
|
|
const result = await agent.verify();
|
|
|
|
// Writer is disconnected after syncAll completes, so cloudCount will be 0
|
|
// Verify the structure of result
|
|
expect(result.results.length).toBeGreaterThan(0);
|
|
expect(result.results[0].localCount).toBe(15);
|
|
});
|
|
});
|
|
|
|
describe('close', () => {
|
|
it('should close all readers and writer', async () => {
|
|
const agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
verbose: true,
|
|
});
|
|
|
|
await agent.initialize();
|
|
await agent.syncAll();
|
|
await agent.close();
|
|
|
|
expect(mockSqliteReader.close).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle reader close failures gracefully', async () => {
|
|
mockSqliteReader.close.mockRejectedValue(new Error('Close failed'));
|
|
|
|
const agent = new CloudSyncAgent({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [
|
|
{
|
|
name: 'test-source',
|
|
type: 'sqlite',
|
|
path: 'test.db',
|
|
targetTable: 'test_table',
|
|
priority: 'high',
|
|
mode: 'full',
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
verbose: true,
|
|
});
|
|
|
|
await agent.initialize();
|
|
await agent.syncAll();
|
|
|
|
// Should not throw
|
|
await expect(agent.close()).resolves.not.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Factory Functions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('createSyncAgent', () => {
|
|
it('should create a CloudSyncAgent instance', () => {
|
|
const agent = createSyncAgent();
|
|
expect(agent).toBeInstanceOf(CloudSyncAgent);
|
|
});
|
|
|
|
it('should pass config to CloudSyncAgent', () => {
|
|
const config = { environment: 'test' };
|
|
const agent = createSyncAgent(config);
|
|
expect(agent).toBeInstanceOf(CloudSyncAgent);
|
|
});
|
|
});
|
|
|
|
describe('syncToCloud', () => {
|
|
let mockWriter: ReturnType<typeof createMockWriter>;
|
|
let mockTunnelManager: ReturnType<typeof createMockTunnelManager>;
|
|
|
|
beforeEach(() => {
|
|
mockWriter = createMockWriter();
|
|
mockTunnelManager = createMockTunnelManager();
|
|
|
|
(createConnectionManager as Mock).mockReturnValue(mockTunnelManager);
|
|
(createPostgresWriter as Mock).mockReturnValue(mockWriter);
|
|
(createSQLiteReader as Mock).mockReturnValue(
|
|
createMockReader('test', 'sqlite', [])
|
|
);
|
|
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
it('should create agent, initialize, sync, and close', async () => {
|
|
const report = await syncToCloud({
|
|
sync: {
|
|
mode: 'full',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [],
|
|
},
|
|
});
|
|
|
|
expect(report).toBeDefined();
|
|
expect(report.status).toBe('completed');
|
|
});
|
|
});
|
|
|
|
describe('syncIncrementalToCloud', () => {
|
|
let mockWriter: ReturnType<typeof createMockWriter>;
|
|
let mockTunnelManager: ReturnType<typeof createMockTunnelManager>;
|
|
|
|
beforeEach(() => {
|
|
mockWriter = createMockWriter();
|
|
mockTunnelManager = createMockTunnelManager();
|
|
|
|
(createConnectionManager as Mock).mockReturnValue(mockTunnelManager);
|
|
(createPostgresWriter as Mock).mockReturnValue(mockWriter);
|
|
(createSQLiteReader as Mock).mockReturnValue(
|
|
createMockReader('test', 'sqlite', [])
|
|
);
|
|
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
it('should perform incremental sync with since date', async () => {
|
|
const since = new Date('2024-01-01');
|
|
const report = await syncIncrementalToCloud(since, {
|
|
sync: {
|
|
mode: 'incremental',
|
|
interval: '1h',
|
|
batchSize: 1000,
|
|
conflictResolution: 'newer-wins',
|
|
sourcePriority: {},
|
|
sources: [],
|
|
},
|
|
});
|
|
|
|
expect(report.mode).toBe('incremental');
|
|
});
|
|
});
|
|
});
|