465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type StorageValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
|
|
|
function createWxRuntime(
|
|
initial: Record<string, StorageValue>,
|
|
options?: {
|
|
onRequest?: (url: string, method: string) => { statusCode: number; data: Record<string, unknown> };
|
|
}
|
|
) {
|
|
const store = new Map<string, StorageValue>(Object.entries(initial));
|
|
const extra = options && typeof options === "object" ? options : {};
|
|
return {
|
|
store,
|
|
getStorageSync: vi.fn((key: string) => store.get(key)),
|
|
setStorageSync: vi.fn((key: string, value: StorageValue) => {
|
|
store.set(key, value);
|
|
}),
|
|
getStorageInfoSync: vi.fn(() => ({ keys: Array.from(store.keys()) })),
|
|
login: vi.fn((options?: { success?: (value: { code: string }) => void }) => {
|
|
options?.success?.({ code: "mock-login-code" });
|
|
}),
|
|
request: vi.fn(
|
|
(options?: {
|
|
url?: string;
|
|
method?: string;
|
|
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
|
}) => {
|
|
const url = String(options?.url || "");
|
|
const method = String(options?.method || "GET").toUpperCase();
|
|
const response = extra.onRequest ? extra.onRequest(url, method) : { statusCode: 200, data: { ok: true } };
|
|
options?.success?.(response);
|
|
}
|
|
)
|
|
};
|
|
}
|
|
|
|
function clearModuleCache() {
|
|
const modulePaths = [
|
|
"./syncService.js",
|
|
"./syncConfigBus.js",
|
|
"./storage.js",
|
|
"./syncAuth.js",
|
|
"./opsConfig.js"
|
|
].map((path) => require.resolve(path));
|
|
modulePaths.forEach((modulePath) => {
|
|
delete require.cache[modulePath];
|
|
});
|
|
}
|
|
|
|
function loadSyncServiceModule() {
|
|
clearModuleCache();
|
|
return require("./syncService.js");
|
|
}
|
|
|
|
describe("miniprogram syncService", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.useFakeTimers();
|
|
delete (globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
delete (global as typeof globalThis & { wx?: unknown }).wx;
|
|
delete (globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__;
|
|
clearModuleCache();
|
|
});
|
|
|
|
it("pickLatest 在远端缺项时保留本地记录,避免读取 null.updatedAt", () => {
|
|
const wxRuntime = createWxRuntime({});
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
|
|
const syncService = loadSyncServiceModule();
|
|
const localRow = {
|
|
id: "srv-local-1",
|
|
name: "local",
|
|
updatedAt: "2026-03-09T10:00:00.000Z"
|
|
};
|
|
|
|
expect(syncService.__test__.pickLatest(localRow, null)).toEqual(localRow);
|
|
});
|
|
|
|
it("pickLatest 在本地缺项时保留远端记录", () => {
|
|
const wxRuntime = createWxRuntime({});
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
|
|
const syncService = loadSyncServiceModule();
|
|
const remoteRow = {
|
|
id: "srv-remote-1",
|
|
name: "remote",
|
|
updatedAt: "2026-03-09T11:00:00.000Z"
|
|
};
|
|
|
|
expect(syncService.__test__.pickLatest(null, remoteRow)).toEqual(remoteRow);
|
|
});
|
|
|
|
it("pickLatestRecord 在终态与普通态冲突时保留终态记录", () => {
|
|
const wxRuntime = createWxRuntime({});
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
|
|
const syncService = loadSyncServiceModule();
|
|
const localRow = {
|
|
id: "rec-local-1",
|
|
content: "local discarded",
|
|
serverId: "",
|
|
category: "问题",
|
|
contextLabel: "",
|
|
processed: false,
|
|
discarded: true,
|
|
createdAt: "2026-03-09T00:00:00.000Z",
|
|
updatedAt: "2026-03-09T10:00:00.000Z",
|
|
deletedAt: null
|
|
};
|
|
const remoteRow = {
|
|
id: "rec-local-1",
|
|
content: "remote normal",
|
|
serverId: "",
|
|
category: "问题",
|
|
contextLabel: "",
|
|
processed: false,
|
|
discarded: false,
|
|
createdAt: "2026-03-09T00:00:00.000Z",
|
|
updatedAt: "2026-03-09T11:00:00.000Z",
|
|
deletedAt: null
|
|
};
|
|
|
|
expect(syncService.__test__.pickLatestRecord(localRow, remoteRow)).toEqual(localRow);
|
|
});
|
|
|
|
it("scheduleRecordsSync 不会用服务端普通态覆盖本地已废弃记录", async () => {
|
|
const wxRuntime = createWxRuntime(
|
|
{
|
|
"remoteconn.settings.v2": {
|
|
syncConfigEnabled: true,
|
|
updatedAt: "2026-03-10T00:00:00.000Z"
|
|
},
|
|
"remoteconn.records.v2": []
|
|
},
|
|
{
|
|
onRequest(url) {
|
|
if (url.endsWith("/api/miniprogram/auth/login")) {
|
|
return {
|
|
statusCode: 200,
|
|
data: {
|
|
ok: true,
|
|
token: "mock-token",
|
|
expiresAt: "2099-01-01T00:00:00.000Z"
|
|
}
|
|
};
|
|
}
|
|
if (url.endsWith("/api/miniprogram/sync/records")) {
|
|
return {
|
|
statusCode: 200,
|
|
data: {
|
|
ok: true,
|
|
records: [
|
|
{
|
|
id: "rec-a",
|
|
content: "alpha",
|
|
serverId: "",
|
|
category: "问题",
|
|
contextLabel: "",
|
|
processed: false,
|
|
discarded: false,
|
|
createdAt: "2026-03-09T00:00:00.000Z",
|
|
updatedAt: "2026-03-11T12:00:00.000Z",
|
|
deletedAt: null
|
|
},
|
|
{
|
|
id: "rec-b",
|
|
content: "beta",
|
|
serverId: "",
|
|
category: "问题",
|
|
contextLabel: "",
|
|
processed: true,
|
|
discarded: false,
|
|
createdAt: "2026-03-09T00:05:00.000Z",
|
|
updatedAt: "2026-03-11T10:05:00.000Z",
|
|
deletedAt: null
|
|
}
|
|
]
|
|
}
|
|
};
|
|
}
|
|
return { statusCode: 200, data: { ok: true } };
|
|
}
|
|
}
|
|
);
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
|
gatewayUrl: "https://gateway.example.com",
|
|
gatewayToken: "token"
|
|
};
|
|
|
|
clearModuleCache();
|
|
const storage = require("./storage.js");
|
|
storage.saveRecords(
|
|
[
|
|
{
|
|
id: "rec-a",
|
|
content: "alpha",
|
|
serverId: "",
|
|
category: "问题",
|
|
contextLabel: "",
|
|
processed: false,
|
|
discarded: true,
|
|
createdAt: "2026-03-09T00:00:00.000Z",
|
|
updatedAt: "2026-03-11T10:00:00.000Z"
|
|
},
|
|
{
|
|
id: "rec-b",
|
|
content: "beta",
|
|
serverId: "",
|
|
category: "问题",
|
|
contextLabel: "",
|
|
processed: true,
|
|
discarded: false,
|
|
createdAt: "2026-03-09T00:05:00.000Z",
|
|
updatedAt: "2026-03-11T10:05:00.000Z"
|
|
}
|
|
],
|
|
{ silentSync: true }
|
|
);
|
|
|
|
const syncService = require("./syncService.js");
|
|
syncService.scheduleRecordsSync();
|
|
await vi.runAllTimersAsync();
|
|
|
|
const rows = storage.listRecords();
|
|
const recordA = rows.find((item: { id: string }) => item.id === "rec-a");
|
|
const recordB = rows.find((item: { id: string }) => item.id === "rec-b");
|
|
|
|
expect(recordA && recordA.discarded).toBe(true);
|
|
expect(recordA && recordA.processed).toBe(false);
|
|
expect(recordB && recordB.processed).toBe(true);
|
|
});
|
|
|
|
it("关闭 syncConfigEnabled 后不会再启动同步任务", async () => {
|
|
const wxRuntime = createWxRuntime({
|
|
"remoteconn.settings.v2": {
|
|
syncConfigEnabled: false,
|
|
updatedAt: "2026-03-10T00:00:00.000Z"
|
|
}
|
|
});
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
|
|
const syncService = loadSyncServiceModule();
|
|
syncService.scheduleSettingsSync();
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
|
|
expect(syncService.__test__.isSyncConfigEnabled()).toBe(false);
|
|
expect(wxRuntime.login).not.toHaveBeenCalled();
|
|
expect(wxRuntime.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("关闭 syncConfigEnabled 后会跳过启动 bootstrap", async () => {
|
|
const wxRuntime = createWxRuntime({
|
|
"remoteconn.settings.v2": {
|
|
syncConfigEnabled: false,
|
|
updatedAt: "2026-03-10T00:00:00.000Z"
|
|
}
|
|
});
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
|
gatewayUrl: "https://gateway.example.com",
|
|
gatewayToken: "token"
|
|
};
|
|
|
|
const syncService = loadSyncServiceModule();
|
|
await expect(syncService.ensureSyncBootstrap()).resolves.toBeNull();
|
|
|
|
expect(wxRuntime.login).not.toHaveBeenCalled();
|
|
expect(wxRuntime.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("重新打开 syncConfigEnabled 时会先 bootstrap 再补推本地配置", async () => {
|
|
const requestUrls: string[] = [];
|
|
const wxRuntime = createWxRuntime(
|
|
{
|
|
"remoteconn.settings.v2": {
|
|
syncConfigEnabled: false,
|
|
uiThemeMode: "dark",
|
|
updatedAt: "2026-03-10T00:00:00.000Z"
|
|
},
|
|
"remoteconn.servers.v2": [],
|
|
"remoteconn.records.v2": []
|
|
},
|
|
{
|
|
onRequest(url, method) {
|
|
requestUrls.push(`${method} ${url}`);
|
|
if (url.endsWith("/api/miniprogram/auth/login")) {
|
|
return {
|
|
statusCode: 200,
|
|
data: {
|
|
ok: true,
|
|
token: "mock-token",
|
|
expiresAt: "2099-01-01T00:00:00.000Z"
|
|
}
|
|
};
|
|
}
|
|
if (url.endsWith("/api/miniprogram/sync/bootstrap")) {
|
|
return {
|
|
statusCode: 200,
|
|
data: {
|
|
ok: true,
|
|
settings: {
|
|
updatedAt: "2026-03-09T00:00:00.000Z",
|
|
data: {
|
|
syncConfigEnabled: true,
|
|
uiThemeMode: "light"
|
|
}
|
|
},
|
|
servers: [],
|
|
records: []
|
|
}
|
|
};
|
|
}
|
|
if (url.endsWith("/api/miniprogram/sync/settings")) {
|
|
return { statusCode: 200, data: { ok: true } };
|
|
}
|
|
if (url.endsWith("/api/miniprogram/sync/servers")) {
|
|
return { statusCode: 200, data: { ok: true, servers: [] } };
|
|
}
|
|
if (url.endsWith("/api/miniprogram/sync/records")) {
|
|
return { statusCode: 200, data: { ok: true, records: [] } };
|
|
}
|
|
return { statusCode: 200, data: { ok: true } };
|
|
}
|
|
}
|
|
);
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
|
gatewayUrl: "https://gateway.example.com",
|
|
gatewayToken: "token"
|
|
};
|
|
|
|
clearModuleCache();
|
|
const storage = require("./storage.js");
|
|
|
|
storage.saveSettings({
|
|
syncConfigEnabled: true,
|
|
uiThemeMode: "dark"
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(requestUrls).toEqual([
|
|
"POST https://gateway.example.com/api/miniprogram/auth/login",
|
|
"GET https://gateway.example.com/api/miniprogram/sync/bootstrap",
|
|
"PUT https://gateway.example.com/api/miniprogram/sync/settings",
|
|
"PUT https://gateway.example.com/api/miniprogram/sync/servers",
|
|
"PUT https://gateway.example.com/api/miniprogram/sync/records"
|
|
]);
|
|
|
|
expect(storage.getSettings().syncConfigEnabled).toBe(true);
|
|
expect(storage.getSettings().uiThemeMode).toBe("dark");
|
|
});
|
|
|
|
it("启动 bootstrap 合并完成后会广播刷新事件,供当前页面立刻重读本地配置", async () => {
|
|
const wxRuntime = createWxRuntime(
|
|
{
|
|
"remoteconn.settings.v2": {
|
|
syncConfigEnabled: true,
|
|
uiThemeMode: "dark",
|
|
updatedAt: "2026-03-10T00:00:00.000Z"
|
|
},
|
|
"remoteconn.servers.v2": [],
|
|
"remoteconn.records.v2": []
|
|
},
|
|
{
|
|
onRequest(url) {
|
|
if (url.endsWith("/api/miniprogram/auth/login")) {
|
|
return {
|
|
statusCode: 200,
|
|
data: {
|
|
ok: true,
|
|
token: "mock-token",
|
|
expiresAt: "2099-01-01T00:00:00.000Z"
|
|
}
|
|
};
|
|
}
|
|
if (url.endsWith("/api/miniprogram/sync/bootstrap")) {
|
|
return {
|
|
statusCode: 200,
|
|
data: {
|
|
ok: true,
|
|
settings: {
|
|
updatedAt: "2026-03-11T00:00:00.000Z",
|
|
data: {
|
|
syncConfigEnabled: true,
|
|
uiThemeMode: "light"
|
|
}
|
|
},
|
|
servers: [
|
|
{
|
|
id: "srv-1",
|
|
name: "remote-1",
|
|
tags: [],
|
|
host: "1.1.1.1",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
password: "",
|
|
privateKey: "",
|
|
passphrase: "",
|
|
certificate: "",
|
|
projectPath: "",
|
|
timeoutSeconds: 15,
|
|
heartbeatSeconds: 10,
|
|
transportMode: "gateway",
|
|
jumpHost: {
|
|
enabled: false,
|
|
host: "",
|
|
port: 22,
|
|
username: "",
|
|
authType: "password"
|
|
},
|
|
jumpPassword: "",
|
|
jumpPrivateKey: "",
|
|
jumpPassphrase: "",
|
|
jumpCertificate: "",
|
|
sortOrder: 1,
|
|
lastConnectedAt: "",
|
|
updatedAt: "2026-03-11T00:00:00.000Z",
|
|
deletedAt: null
|
|
}
|
|
],
|
|
records: []
|
|
}
|
|
};
|
|
}
|
|
return { statusCode: 200, data: { ok: true } };
|
|
}
|
|
}
|
|
);
|
|
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
|
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
|
gatewayUrl: "https://gateway.example.com",
|
|
gatewayToken: "token"
|
|
};
|
|
|
|
clearModuleCache();
|
|
const syncConfigBus = require("./syncConfigBus.js");
|
|
const listener = vi.fn();
|
|
const unsubscribe = syncConfigBus.subscribeSyncConfigApplied(listener);
|
|
const syncService = require("./syncService.js");
|
|
|
|
await expect(syncService.ensureSyncBootstrap()).resolves.toMatchObject({
|
|
ok: true
|
|
});
|
|
|
|
expect(listener).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: "bootstrap",
|
|
hasSettings: true,
|
|
serverCount: 1,
|
|
recordCount: 0
|
|
})
|
|
);
|
|
|
|
unsubscribe();
|
|
});
|
|
});
|