| import { beforeEach, describe, expect, it, vi } from "vitest"; |
| import { ErrorCodes } from "../protocol/index.js"; |
| import { pushHandlers } from "./push.js"; |
|
|
| const mocks = vi.hoisted(() => ({ |
| loadConfig: vi.fn(() => ({})), |
| })); |
|
|
| vi.mock("../../config/config.js", () => ({ |
| loadConfig: mocks.loadConfig, |
| })); |
|
|
| vi.mock("../../infra/push-apns.js", () => ({ |
| clearApnsRegistrationIfCurrent: vi.fn(), |
| loadApnsRegistration: vi.fn(), |
| normalizeApnsEnvironment: vi.fn(), |
| resolveApnsAuthConfigFromEnv: vi.fn(), |
| resolveApnsRelayConfigFromEnv: vi.fn(), |
| sendApnsAlert: vi.fn(), |
| shouldClearStoredApnsRegistration: vi.fn(), |
| })); |
|
|
| import { |
| type ApnsPushResult, |
| type ApnsRegistration, |
| clearApnsRegistrationIfCurrent, |
| loadApnsRegistration, |
| normalizeApnsEnvironment, |
| resolveApnsAuthConfigFromEnv, |
| resolveApnsRelayConfigFromEnv, |
| sendApnsAlert, |
| shouldClearStoredApnsRegistration, |
| } from "../../infra/push-apns.js"; |
|
|
| type RespondCall = [boolean, unknown?, { code: number; message: string }?]; |
|
|
| const DEFAULT_DIRECT_REGISTRATION = { |
| nodeId: "ios-node-1", |
| transport: "direct", |
| token: "abcd", |
| topic: "ai.openclaw.ios", |
| environment: "sandbox", |
| updatedAtMs: 1, |
| } as const; |
|
|
| const DEFAULT_RELAY_REGISTRATION = { |
| nodeId: "ios-node-1", |
| transport: "relay", |
| relayHandle: "relay-handle-123", |
| sendGrant: "send-grant-123", |
| installationId: "install-123", |
| topic: "ai.openclaw.ios", |
| environment: "production", |
| distribution: "official", |
| updatedAtMs: 1, |
| tokenDebugSuffix: "abcd1234", |
| } as const; |
|
|
| function directRegistration( |
| overrides: Partial<Extract<ApnsRegistration, { transport: "direct" }>> = {}, |
| ): Extract<ApnsRegistration, { transport: "direct" }> { |
| return { ...DEFAULT_DIRECT_REGISTRATION, ...overrides }; |
| } |
|
|
| function relayRegistration( |
| overrides: Partial<Extract<ApnsRegistration, { transport: "relay" }>> = {}, |
| ): Extract<ApnsRegistration, { transport: "relay" }> { |
| return { ...DEFAULT_RELAY_REGISTRATION, ...overrides }; |
| } |
|
|
| function mockDirectAuth() { |
| vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ |
| ok: true, |
| value: { |
| teamId: "TEAM123", |
| keyId: "KEY123", |
| privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", |
| }, |
| }); |
| } |
|
|
| function apnsResult(overrides: Partial<ApnsPushResult>): ApnsPushResult { |
| return { |
| ok: true, |
| status: 200, |
| tokenSuffix: "1234abcd", |
| topic: "ai.openclaw.ios", |
| environment: "sandbox", |
| transport: "direct", |
| ...overrides, |
| }; |
| } |
|
|
| function createInvokeParams(params: Record<string, unknown>) { |
| const respond = vi.fn(); |
| return { |
| respond, |
| invoke: async () => |
| await pushHandlers["push.test"]({ |
| params, |
| respond: respond as never, |
| context: {} as never, |
| client: null, |
| req: { type: "req", id: "req-1", method: "push.test" }, |
| isWebchatConnect: () => false, |
| }), |
| }; |
| } |
|
|
| function expectInvalidRequestResponse( |
| respond: ReturnType<typeof vi.fn>, |
| expectedMessagePart: string, |
| ) { |
| const call = respond.mock.calls[0] as RespondCall | undefined; |
| expect(call?.[0]).toBe(false); |
| expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); |
| expect(call?.[2]?.message).toContain(expectedMessagePart); |
| } |
|
|
| describe("push.test handler", () => { |
| beforeEach(() => { |
| mocks.loadConfig.mockClear(); |
| mocks.loadConfig.mockReturnValue({}); |
| vi.mocked(loadApnsRegistration).mockClear(); |
| vi.mocked(normalizeApnsEnvironment).mockClear(); |
| vi.mocked(resolveApnsAuthConfigFromEnv).mockClear(); |
| vi.mocked(resolveApnsRelayConfigFromEnv).mockClear(); |
| vi.mocked(sendApnsAlert).mockClear(); |
| vi.mocked(clearApnsRegistrationIfCurrent).mockClear(); |
| vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); |
| }); |
|
|
| it("rejects invalid params", async () => { |
| const { respond, invoke } = createInvokeParams({ title: "hello" }); |
| await invoke(); |
| expectInvalidRequestResponse(respond, "invalid push.test params"); |
| }); |
|
|
| it("returns invalid request when node has no APNs registration", async () => { |
| vi.mocked(loadApnsRegistration).mockResolvedValue(null); |
| const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" }); |
| await invoke(); |
| expectInvalidRequestResponse(respond, "has no APNs registration"); |
| }); |
|
|
| it("sends push test when registration and auth are available", async () => { |
| vi.mocked(loadApnsRegistration).mockResolvedValue(directRegistration()); |
| mockDirectAuth(); |
| vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); |
| vi.mocked(sendApnsAlert).mockResolvedValue(apnsResult({})); |
|
|
| const { respond, invoke } = createInvokeParams({ |
| nodeId: "ios-node-1", |
| title: "Wake", |
| body: "Ping", |
| }); |
| await invoke(); |
|
|
| expect(sendApnsAlert).toHaveBeenCalledTimes(1); |
| const call = respond.mock.calls[0] as RespondCall | undefined; |
| expect(call?.[0]).toBe(true); |
| expect(call?.[1]).toMatchObject({ ok: true, status: 200 }); |
| }); |
|
|
| it("sends push test through relay registrations", async () => { |
| mocks.loadConfig.mockReturnValue({ |
| gateway: { |
| push: { |
| apns: { |
| relay: { |
| baseUrl: "https://relay.example.com", |
| timeoutMs: 1000, |
| }, |
| }, |
| }, |
| }, |
| }); |
| vi.mocked(loadApnsRegistration).mockResolvedValue( |
| relayRegistration({ installationId: "install-1" }), |
| ); |
| vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({ |
| ok: true, |
| value: { |
| baseUrl: "https://relay.example.com", |
| timeoutMs: 1000, |
| }, |
| }); |
| vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); |
| vi.mocked(sendApnsAlert).mockResolvedValue( |
| apnsResult({ |
| tokenSuffix: "abcd1234", |
| environment: "production", |
| transport: "relay", |
| }), |
| ); |
|
|
| const { respond, invoke } = createInvokeParams({ |
| nodeId: "ios-node-1", |
| title: "Wake", |
| body: "Ping", |
| }); |
| await invoke(); |
|
|
| expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled(); |
| expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1); |
| expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { |
| push: { |
| apns: { |
| relay: { |
| baseUrl: "https://relay.example.com", |
| timeoutMs: 1000, |
| }, |
| }, |
| }, |
| }); |
| expect(sendApnsAlert).toHaveBeenCalledTimes(1); |
| const call = respond.mock.calls[0] as RespondCall | undefined; |
| expect(call?.[0]).toBe(true); |
| expect(call?.[1]).toMatchObject({ ok: true, status: 200, transport: "relay" }); |
| }); |
|
|
| it("clears stale registrations after invalid token push-test failures", async () => { |
| const registration = directRegistration(); |
| vi.mocked(loadApnsRegistration).mockResolvedValue(registration); |
| mockDirectAuth(); |
| vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); |
| vi.mocked(sendApnsAlert).mockResolvedValue( |
| apnsResult({ |
| ok: false, |
| status: 400, |
| reason: "BadDeviceToken", |
| }), |
| ); |
| vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(true); |
|
|
| const { invoke } = createInvokeParams({ |
| nodeId: "ios-node-1", |
| title: "Wake", |
| body: "Ping", |
| }); |
| await invoke(); |
|
|
| expect(clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ |
| nodeId: "ios-node-1", |
| registration, |
| }); |
| }); |
|
|
| it("does not clear relay registrations after invalidation-shaped failures", async () => { |
| const registration = relayRegistration(); |
| vi.mocked(loadApnsRegistration).mockResolvedValue(registration); |
| vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({ |
| ok: true, |
| value: { |
| baseUrl: "https://relay.example.com", |
| timeoutMs: 1000, |
| }, |
| }); |
| vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); |
| const result = apnsResult({ |
| ok: false, |
| status: 410, |
| reason: "Unregistered", |
| tokenSuffix: "abcd1234", |
| environment: "production", |
| transport: "relay", |
| }); |
| vi.mocked(sendApnsAlert).mockResolvedValue(result); |
| vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); |
|
|
| const { invoke } = createInvokeParams({ |
| nodeId: "ios-node-1", |
| title: "Wake", |
| body: "Ping", |
| }); |
| await invoke(); |
|
|
| expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ |
| registration, |
| result, |
| overrideEnvironment: null, |
| }); |
| expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled(); |
| }); |
|
|
| it("does not clear direct registrations when push.test overrides the environment", async () => { |
| const registration = directRegistration(); |
| vi.mocked(loadApnsRegistration).mockResolvedValue(registration); |
| mockDirectAuth(); |
| vi.mocked(normalizeApnsEnvironment).mockReturnValue("production"); |
| const result = apnsResult({ |
| ok: false, |
| status: 400, |
| reason: "BadDeviceToken", |
| environment: "production", |
| }); |
| vi.mocked(sendApnsAlert).mockResolvedValue(result); |
| vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); |
|
|
| const { invoke } = createInvokeParams({ |
| nodeId: "ios-node-1", |
| title: "Wake", |
| body: "Ping", |
| environment: "production", |
| }); |
| await invoke(); |
|
|
| expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ |
| registration, |
| result, |
| overrideEnvironment: "production", |
| }); |
| expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|