mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createDefaultSyncHostsState,
|
||||
getSyncHostsPath,
|
||||
isValidSyncHost,
|
||||
normalizeSyncHostsState,
|
||||
readSyncHostsState,
|
||||
recordSyncResult,
|
||||
removeSyncHost,
|
||||
upsertSyncHost,
|
||||
writeSyncHostsState,
|
||||
} from './sync-hosts-store';
|
||||
|
||||
function withTempDir(fn: (dir: string) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-hosts-test-'));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('createDefaultSyncHostsState returns empty v1 state', () => {
|
||||
assert.deepEqual(createDefaultSyncHostsState(), {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: 30,
|
||||
hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('isValidSyncHost accepts ssh destinations and rejects unsafe values', () => {
|
||||
assert.equal(isValidSyncHost('desktop'), true);
|
||||
assert.equal(isValidSyncHost('user@10.0.0.5'), true);
|
||||
assert.equal(isValidSyncHost('my-alias.local'), true);
|
||||
assert.equal(isValidSyncHost(''), false);
|
||||
assert.equal(isValidSyncHost(' '), false);
|
||||
assert.equal(isValidSyncHost('-oProxyCommand=evil'), false);
|
||||
assert.equal(isValidSyncHost('host with spaces'), false);
|
||||
});
|
||||
|
||||
test('upsertSyncHost adds a new host with defaults and preserves fields on update', () => {
|
||||
const state = createDefaultSyncHostsState();
|
||||
const added = upsertSyncHost(state, { host: 'user@laptop' }, 1000);
|
||||
assert.deepEqual(added.hosts, [
|
||||
{
|
||||
host: 'user@laptop',
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: 1000,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
},
|
||||
]);
|
||||
// original state untouched
|
||||
assert.equal(state.hosts.length, 0);
|
||||
|
||||
const synced = recordSyncResult(added, 'user@laptop', {
|
||||
atMs: 2000,
|
||||
status: 'success',
|
||||
detail: 'Sessions merged: 3',
|
||||
});
|
||||
const updated = upsertSyncHost(synced, { host: 'user@laptop', direction: 'push' }, 3000);
|
||||
assert.equal(updated.hosts.length, 1);
|
||||
const entry = updated.hosts[0]!;
|
||||
assert.equal(entry.direction, 'push');
|
||||
assert.equal(entry.addedAtMs, 1000);
|
||||
assert.equal(entry.lastSyncAtMs, 2000);
|
||||
assert.equal(entry.lastSyncStatus, 'success');
|
||||
assert.equal(entry.lastSyncDetail, 'Sessions merged: 3');
|
||||
});
|
||||
|
||||
test('upsertSyncHost trims host and throws on invalid host', () => {
|
||||
const state = upsertSyncHost(createDefaultSyncHostsState(), { host: ' desktop ' }, 1);
|
||||
assert.equal(state.hosts[0]!.host, 'desktop');
|
||||
assert.throws(() => upsertSyncHost(state, { host: '-bad' }, 2));
|
||||
});
|
||||
|
||||
test('removeSyncHost removes only the matching host', () => {
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'a' }, 1);
|
||||
state = upsertSyncHost(state, { host: 'b' }, 2);
|
||||
const removed = removeSyncHost(state, 'a');
|
||||
assert.deepEqual(
|
||||
removed.hosts.map((entry) => entry.host),
|
||||
['b'],
|
||||
);
|
||||
assert.equal(removeSyncHost(removed, 'missing').hosts.length, 1);
|
||||
});
|
||||
|
||||
test('recordSyncResult upserts unknown hosts so CLI syncs are remembered', () => {
|
||||
const state = recordSyncResult(createDefaultSyncHostsState(), 'user@server', {
|
||||
atMs: 5000,
|
||||
status: 'error',
|
||||
detail: 'Remote merge failed',
|
||||
});
|
||||
assert.equal(state.hosts.length, 1);
|
||||
const entry = state.hosts[0]!;
|
||||
assert.equal(entry.host, 'user@server');
|
||||
assert.equal(entry.addedAtMs, 5000);
|
||||
assert.equal(entry.lastSyncAtMs, 5000);
|
||||
assert.equal(entry.lastSyncStatus, 'error');
|
||||
assert.equal(entry.lastSyncDetail, 'Remote merge failed');
|
||||
});
|
||||
|
||||
test('normalizeSyncHostsState drops invalid entries and falls back to defaults', () => {
|
||||
assert.deepEqual(normalizeSyncHostsState(null), createDefaultSyncHostsState());
|
||||
assert.deepEqual(normalizeSyncHostsState('junk'), createDefaultSyncHostsState());
|
||||
assert.deepEqual(normalizeSyncHostsState({ version: 99 }), createDefaultSyncHostsState());
|
||||
|
||||
const normalized = normalizeSyncHostsState({
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: 15,
|
||||
hosts: [
|
||||
{
|
||||
host: 'desktop',
|
||||
label: 'Desktop PC',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
addedAtMs: 10,
|
||||
lastSyncAtMs: 20,
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncDetail: 'ok',
|
||||
},
|
||||
{ host: '-bad', direction: 'both', autoSync: false, addedAtMs: 1 },
|
||||
{ host: 'dupe', direction: 'sideways', autoSync: false, addedAtMs: 1 },
|
||||
'not-an-object',
|
||||
],
|
||||
});
|
||||
assert.equal(normalized.autoSyncIntervalMinutes, 15);
|
||||
assert.deepEqual(normalized.hosts, [
|
||||
{
|
||||
host: 'desktop',
|
||||
label: 'Desktop PC',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
addedAtMs: 10,
|
||||
lastSyncAtMs: 20,
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncDetail: 'ok',
|
||||
},
|
||||
{
|
||||
host: 'dupe',
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: 1,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('read/write round-trips state and tolerates missing or corrupt files', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = getSyncHostsPath(root);
|
||||
assert.equal(filePath, path.join(root, 'sync-hosts.json'));
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
|
||||
fs.writeFileSync(filePath, '{corrupt');
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
|
||||
writeSyncHostsState(filePath, state);
|
||||
assert.deepEqual(readSyncHostsState(filePath), state);
|
||||
});
|
||||
});
|
||||
|
||||
test('writeSyncHostsState creates parent directories', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = path.join(root, 'nested', 'dir', 'sync-hosts.json');
|
||||
writeSyncHostsState(filePath, createDefaultSyncHostsState());
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user