diff --git a/src/commands/doctor/legacy/push-apns.test.ts b/src/commands/doctor/legacy/push-apns.test.ts new file mode 100644 index 00000000000..95ded7f0dce --- /dev/null +++ b/src/commands/doctor/legacy/push-apns.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadApnsRegistration } from "../../../infra/push-apns.js"; +import { createTrackedTempDirs } from "../../../test-utils/tracked-temp-dirs.js"; +import { + importLegacyApnsRegistrationFileToSqlite, + legacyApnsRegistrationFileExists, +} from "./push-apns.js"; + +const tempDirs = createTrackedTempDirs(); + +afterEach(async () => { + await tempDirs.cleanup(); +}); + +async function makeTempDir(): Promise { + return await tempDirs.make("openclaw-doctor-push-apns-test-"); +} + +async function writeLegacyApnsState(baseDir: string, value: unknown): Promise { + const statePath = path.join(baseDir, "push", "apns-registrations.json"); + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + return statePath; +} + +describe("legacy APNs registration migration", () => { + it("imports legacy registrations into SQLite and removes the source", async () => { + const baseDir = await makeTempDir(); + const statePath = await writeLegacyApnsState(baseDir, { + registrationsByNodeId: { + " ios-node-legacy ": { + nodeId: " ios-node-legacy ", + token: "", + topic: " ai.openclaw.ios ", + environment: " PRODUCTION ", + updatedAtMs: 3, + }, + " ": { + nodeId: " ios-node-fallback ", + token: "", + topic: " ai.openclaw.ios ", + updatedAtMs: 2, + }, + "ios-node-bad-relay": { + transport: "relay", + nodeId: "ios-node-bad-relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "beta", + updatedAtMs: 1, + }, + }, + }); + + await expect(importLegacyApnsRegistrationFileToSqlite(baseDir)).resolves.toEqual({ + imported: true, + registrations: 2, + }); + + await expect(fs.stat(statePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(loadApnsRegistration("ios-node-legacy", baseDir)).resolves.toMatchObject({ + nodeId: "ios-node-legacy", + transport: "direct", + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "production", + updatedAtMs: 3, + }); + await expect(loadApnsRegistration("ios-node-fallback", baseDir)).resolves.toMatchObject({ + nodeId: "ios-node-fallback", + transport: "direct", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 2, + }); + await expect(loadApnsRegistration("ios-node-bad-relay", baseDir)).resolves.toBeNull(); + }); + + it("leaves malformed legacy registration state untouched", async () => { + const baseDir = await makeTempDir(); + await writeLegacyApnsState(baseDir, []); + + await expect(importLegacyApnsRegistrationFileToSqlite(baseDir)).resolves.toEqual({ + imported: false, + registrations: 0, + }); + await expect(legacyApnsRegistrationFileExists(baseDir)).resolves.toBe(true); + }); + + it("skips when the legacy registration file is missing", async () => { + const baseDir = await makeTempDir(); + + await expect(importLegacyApnsRegistrationFileToSqlite(baseDir)).resolves.toEqual({ + imported: false, + registrations: 0, + }); + await expect(legacyApnsRegistrationFileExists(baseDir)).resolves.toBe(false); + }); +}); diff --git a/src/infra/push-apns.store.test.ts b/src/infra/push-apns.store.test.ts index a0c0de5d12a..0706c45cc22 100644 --- a/src/infra/push-apns.store.test.ts +++ b/src/infra/push-apns.store.test.ts @@ -1,7 +1,4 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { importLegacyApnsRegistrationFileToSqlite } from "../commands/doctor/legacy/push-apns.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { clearApnsRegistration, @@ -76,74 +73,8 @@ describe("push APNs registration store", () => { expect(loaded && "token" in loaded).toBe(false); }); - it("normalizes legacy direct records from disk and ignores invalid entries", async () => { + it("falls back cleanly for missing registration state", async () => { const baseDir = await makeTempDir(); - const statePath = path.join(baseDir, "push", "apns-registrations.json"); - await fs.mkdir(path.dirname(statePath), { recursive: true }); - await fs.writeFile( - statePath, - `${JSON.stringify( - { - registrationsByNodeId: { - " ios-node-legacy ": { - nodeId: " ios-node-legacy ", - token: "", - topic: " ai.openclaw.ios ", - environment: " PRODUCTION ", - updatedAtMs: 3, - }, - " ": { - nodeId: " ios-node-fallback ", - token: "", - topic: " ai.openclaw.ios ", - updatedAtMs: 2, - }, - "ios-node-bad-relay": { - transport: "relay", - nodeId: "ios-node-bad-relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "beta", - updatedAtMs: 1, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - await expect(importLegacyApnsRegistrationFileToSqlite(baseDir)).resolves.toEqual({ - imported: true, - registrations: 2, - }); - await expect(loadApnsRegistration("ios-node-legacy", baseDir)).resolves.toMatchObject({ - nodeId: "ios-node-legacy", - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - updatedAtMs: 3, - }); - await expect(loadApnsRegistration("ios-node-fallback", baseDir)).resolves.toMatchObject({ - nodeId: "ios-node-fallback", - transport: "direct", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 2, - }); - await expect(loadApnsRegistration("ios-node-bad-relay", baseDir)).resolves.toBeNull(); - }); - - it("falls back cleanly for malformed or missing registration state", async () => { - const baseDir = await makeTempDir(); - const statePath = path.join(baseDir, "push", "apns-registrations.json"); - await fs.mkdir(path.dirname(statePath), { recursive: true }); - await fs.writeFile(statePath, "[]", "utf8"); await expect(loadApnsRegistration("ios-node-missing", baseDir)).resolves.toBeNull(); await expect(loadApnsRegistration(" ", baseDir)).resolves.toBeNull(); diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index c8e8958c1e1..b6f82675187 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -399,7 +399,7 @@ async function persistRegistrationsState( export function normalizeApnsRegistrationStateSnapshot( parsed: unknown, ): ApnsRegistrationState | null { - if (!parsed || typeof parsed !== "object") { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } const registrations =