import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Err, Ok } from '@nangohq/utils';
import { RunnerMonitor } from './monitor.js';
import type { PersistClient } from './clients/persist.js';
import type { DBSyncConfig, NangoProps, ScriptType } from '@nangohq/types';
vi.hoisted(() => {
vi.stubEnv('RUNNER_NODE_ID', '1');
});
function createNangoProps(overrides: { scriptType: ScriptType; syncId: string; environmentId: number }): NangoProps {
return {
scriptType: overrides.scriptType,
host: 'http://localhost:3003',
connectionId: 'connection-id',
environmentId: overrides.environmentId,
environmentName: 'dev',
providerConfigKey: 'provider-config-key',
provider: 'provider',
activityLogId: '1',
secretKey: 'secret-key',
nangoConnectionId: 1,
syncId: overrides.syncId,
syncJobId: 1,
lastSyncDate: new Date(),
attributes: {},
track_deletes: false,
syncConfig: {} as DBSyncConfig,
debug: false,
startedAt: new Date(),
team: { id: 1, name: 'dev' },
logger: { level: 'off' },
runnerFlags: {
validateActionInput: false,
validateActionOutput: false,
validateSyncMetadata: false,
validateSyncRecords: false,
exportRunnerTelemetry: false
},
endUser: null,
heartbeatTimeoutSecs: 30
};
}
function createPersistClientMock() {
const putSyncConflict = vi.fn().mockResolvedValue(Ok(undefined));
const deleteSyncConflict = vi.fn().mockResolvedValue(Ok(undefined));
return {
client: { putSyncConflict, deleteSyncConflict } as unknown as PersistClient,
putSyncConflict,
deleteSyncConflict
};
}
describe('RunnerMonitor conflict tracking', () => {
let monitor: RunnerMonitor;
beforeEach(() => {
vi.spyOn(global, 'setInterval').mockImplementation(() => null as unknown as NodeJS.Timeout);
vi.spyOn(global, 'setTimeout').mockImplementation(() => null as unknown as NodeJS.Timeout);
monitor = new RunnerMonitor({ runnerId: 1 });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('distributed conflicts', () => {
let putSyncConflict: ReturnType<typeof vi.fn>;
let deleteSyncConflict: ReturnType<typeof vi.fn>;
let persistClient: PersistClient;
beforeEach(() => {
const mock = createPersistClientMock();
putSyncConflict = mock.putSyncConflict;
deleteSyncConflict = mock.deleteSyncConflict;
persistClient = mock.client;
});
describe('track', () => {
it('acquires sync conflict lock when scriptType is sync', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-123', environmentId: 1 });
await monitor.track(nangoProps, 'task-1', { persistClient });
expect(putSyncConflict).toHaveBeenCalledWith({
environmentId: 1,
scriptType: 'sync',
syncId: 'sync-123',
refresh: false,
ttlMs: expect.any(Number)
});
});
it('does not acquire sync conflict lock when scriptType is not sync', async () => {
const nangoProps = createNangoProps({ scriptType: 'webhook', syncId: 'webhook-789', environmentId: 1 });
await monitor.track(nangoProps, 'task-3', { persistClient });
expect(putSyncConflict).not.toHaveBeenCalled();
});
it('removes tracked entry when sync conflict acquisition fails', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-conflict', environmentId: 1 });
await monitor.track(nangoProps, 'task-1', { persistClient });
putSyncConflict.mockResolvedValueOnce(Err(new Error('Conflicting sync detected')));
await expect(monitor.track(nangoProps, 'task-2', { persistClient })).rejects.toThrow('Conflicting sync detected');
expect((monitor as unknown as { tracked: Map<string, unknown> }).tracked.size).toBe(1);
expect((monitor as unknown as { tracked: Map<string, unknown> }).tracked.has('task-2')).toBe(false);
});
});
describe('untrack', () => {
it('releases sync conflict lock when task had tracked sync', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-untrack', environmentId: 1 });
await monitor.track(nangoProps, 'task-untrack', { persistClient });
await monitor.untrack('task-untrack');
expect(deleteSyncConflict).toHaveBeenCalledWith({
environmentId: 1,
scriptType: 'sync',
syncId: 'sync-untrack'
});
});
});
describe('trackForConflicts', () => {
it('refreshes sync conflict lock via persist client', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-refresh', environmentId: 1 });
await monitor.track(nangoProps, 'task-refresh', { persistClient });
await monitor.trackForConflicts('task-refresh', { refresh: true });
expect(putSyncConflict).toHaveBeenLastCalledWith({
environmentId: 1,
scriptType: 'sync',
syncId: 'sync-refresh',
refresh: true,
ttlMs: expect.any(Number)
});
});
});
});
describe('local conflicts', () => {
it('tracks sync conflicts in-process when no persist client is passed', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-local', environmentId: 1 });
await monitor.track(nangoProps, 'task-local');
await expect(monitor.track(nangoProps, 'task-local-2')).rejects.toThrow('Conflicting sync detected');
expect((monitor as unknown as { tracked: Map<string, unknown> }).tracked.size).toBe(1);
expect((monitor as unknown as { tracked: Map<string, unknown> }).tracked.has('task-local-2')).toBe(false);
});
it('releases local sync conflict on untrack', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-release', environmentId: 1 });
await monitor.track(nangoProps, 'task-release');
await monitor.untrack('task-release');
await expect(monitor.track(nangoProps, 'task-release-2')).resolves.toBeUndefined();
});
it('refreshes local sync conflict on heartbeat', async () => {
const nangoProps = createNangoProps({ scriptType: 'sync', syncId: 'sync-heartbeat', environmentId: 1 });
await monitor.track(nangoProps, 'task-heartbeat');
await expect(monitor.trackForConflicts('task-heartbeat', { refresh: true })).resolves.toBeUndefined();
});
});
});