153 lines
5.4 KiB
JavaScript
153 lines
5.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
classifySyncError,
|
|
harborReducer,
|
|
initialHarborState,
|
|
} from '../../.test-dist/src/web/state/harborReducer.js';
|
|
import { parseHarborState } from '../../.test-dist/src/web/api/harborClient.js';
|
|
import { createStateSnapshot } from '../../.test-dist/src/shared/contracts/state.js';
|
|
|
|
const snapshot = (revision, desiredServerId = '', serverIds = ['one', 'two']) => ({
|
|
apiVersion: 1,
|
|
revision,
|
|
profiles: [{
|
|
id: 'primary',
|
|
desiredServerId,
|
|
servers: serverIds.map((id) => ({ id })),
|
|
}],
|
|
selection: { desiredProfileId: 'primary', desiredServerId },
|
|
servers: serverIds.map((id) => ({ id })),
|
|
});
|
|
|
|
const receive = (state, value) => harborReducer(state, {
|
|
type: 'sync-succeeded',
|
|
snapshot: value,
|
|
receivedAt: `2026-07-11T12:00:0${value.revision}.000Z`,
|
|
});
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((done) => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
test('typed Harbor client validates unknown state and isolates wire compatibility fields', () => {
|
|
const snapshot = createStateSnapshot({
|
|
storedState: {},
|
|
runtime: { running: false },
|
|
gatewayAuto: null,
|
|
appMode: 'client',
|
|
configExists: false,
|
|
subscriptionHost: '',
|
|
now: new Date('2026-07-11T12:00:00.000Z'),
|
|
});
|
|
const parsed = parseHarborState({
|
|
...snapshot,
|
|
proxyPort: 9082,
|
|
configExists: true,
|
|
gatewayAuto: { available: true },
|
|
});
|
|
|
|
assert.deepEqual(parsed.clientRuntime, {
|
|
proxyPort: 9082,
|
|
configured: true,
|
|
gatewayAvailable: true,
|
|
});
|
|
assert.equal(Object.hasOwn(parsed, 'proxyPort'), false);
|
|
let incompatible;
|
|
try {
|
|
parseHarborState({ ...snapshot, revision: -1 });
|
|
} catch (error) {
|
|
incompatible = error;
|
|
}
|
|
assert.equal(incompatible.code, 'INCOMPATIBLE_API');
|
|
const failed = harborReducer(initialHarborState, { type: 'sync-failed', error: incompatible });
|
|
assert.equal(failed.transport.bootStatus, 'incompatible-api');
|
|
});
|
|
|
|
test('an old rules snapshot remains readable but keeps the mutation capability absent', () => {
|
|
const current = createStateSnapshot({
|
|
storedState: {
|
|
routeRules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }],
|
|
appliedRouteRules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }],
|
|
},
|
|
runtime: { running: true },
|
|
gatewayAuto: null,
|
|
appMode: 'client',
|
|
configExists: true,
|
|
now: new Date('2026-08-17T12:00:00.000Z'),
|
|
});
|
|
const legacy = structuredClone(current);
|
|
delete legacy.route.rulesContractVersion;
|
|
for (const rule of [...legacy.route.localRules, ...legacy.route.activeLocalRules]) delete rule.outbound;
|
|
|
|
const parsed = parseHarborState(legacy);
|
|
assert.equal(parsed.route.rulesContractVersion, undefined);
|
|
assert.deepEqual(parsed.route.localRules, [
|
|
{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' },
|
|
]);
|
|
assert.equal(parseHarborState(current).route.rulesContractVersion, 2);
|
|
});
|
|
|
|
test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => {
|
|
let state = receive(initialHarborState, snapshot(1, 'one'));
|
|
const poll = deferred();
|
|
const mutation = deferred();
|
|
const apply = (promise) => promise.then((value) => { state = receive(state, value); });
|
|
const pollApplied = apply(poll.promise);
|
|
const mutationApplied = apply(mutation.promise);
|
|
|
|
mutation.resolve(snapshot(3, 'two'));
|
|
await mutationApplied;
|
|
poll.resolve(snapshot(2, 'one'));
|
|
await pollApplied;
|
|
|
|
assert.equal(state.snapshot.revision, 3);
|
|
assert.equal(state.snapshot.selection.desiredServerId, 'two');
|
|
});
|
|
|
|
test('an equal revision keeps the current snapshot identity', () => {
|
|
const state = receive(initialHarborState, snapshot(4, 'one'));
|
|
const next = receive(state, snapshot(4, 'two'));
|
|
|
|
assert.equal(next.snapshot, state.snapshot);
|
|
assert.equal(next.snapshot.selection.desiredServerId, 'one');
|
|
});
|
|
|
|
test('selection has no client-side shadow and follows canonical profile snapshots', () => {
|
|
const state = receive(initialHarborState, snapshot(2, 'two'));
|
|
assert.equal(Object.hasOwn(state, 'pendingServerId'), false);
|
|
assert.equal(state.snapshot.profiles[0].desiredServerId, 'two');
|
|
});
|
|
|
|
test('data invariant: initial control outage is retryable without a fabricated snapshot', () => {
|
|
const serverError = Object.assign(new Error('Internal Server Error'), { status: 500 });
|
|
const refused = new TypeError('fetch failed');
|
|
|
|
assert.equal(classifySyncError(serverError), 'control-unreachable');
|
|
assert.equal(classifySyncError(refused), 'control-unreachable');
|
|
const failed = harborReducer(initialHarborState, { type: 'sync-failed', error: serverError });
|
|
assert.equal(failed.transport.bootStatus, 'control-unreachable');
|
|
assert.equal(failed.snapshot, null);
|
|
});
|
|
|
|
test('ready state becomes stale after three failures and recovers on success', () => {
|
|
const known = snapshot(1, 'one');
|
|
let state = receive(initialHarborState, known);
|
|
const error = new TypeError('fetch failed');
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
state = harborReducer(state, { type: 'sync-failed', error });
|
|
}
|
|
assert.equal(state.snapshot, known);
|
|
assert.equal(state.transport.stale, true);
|
|
assert.equal(state.transport.consecutiveFailures, 3);
|
|
|
|
state = receive(state, snapshot(2, 'two'));
|
|
assert.equal(state.transport.stale, false);
|
|
assert.equal(state.transport.consecutiveFailures, 0);
|
|
assert.equal(state.snapshot.selection.desiredServerId, 'two');
|
|
});
|