import { describe, expect, it } from "@suaveplan/testing/runner"; import { MacOSContactsError } from "../error-codes/error-codes.js"; import type { AppleScriptRunner } from "../executor/executor.js"; import { MacOSContactsBridge } from "./bridge.js"; function fixtureContact(id: string, first: string, last: string): string { return [ `${id}|||${first}|||${last}|||Org||| ||| |||false`, `PHONES:mobile:+1555${id.padStart(7, "0")};;;`, `EMAILS:work:${first.toLowerCase()}@test.com;;;`, "ADDRESSES:", ].join("\n"); } const singleFixture = fixtureContact("c1", "Alice", "Smith"); const multiFixture = [ fixtureContact("c1", "Alice", "Smith"), "---CONTACT---", fixtureContact("c2", "Bob", "Jones"), ].join("\n"); const groupsFixture = ["g1|||Family|||3", "g2|||Coworkers|||8"].join("\n"); function recordingRunner(behaviour: Record): { runner: AppleScriptRunner; calls: string[]; } { const calls: string[] = []; return { calls, runner: { async run(script: string): Promise { calls.push(script); for (const [key, value] of Object.entries(behaviour)) { if (script.includes(key)) { if (value instanceof Error) throw value; return value; } } throw new Error(`Unexpected script: ${script}`); }, }, }; } function alwaysFailingRunner(message: string): AppleScriptRunner { return { async run(): Promise { throw new Error(message); }, }; } describe("MacOSContactsBridge", () => { it("constructor defaults to the platform runner", { timeout: 5000 }, () => { const bridge = new MacOSContactsBridge({ timeoutMs: 5000 }); expect(bridge).toBeInstanceOf(MacOSContactsBridge); }); it("constructor accepts an injected runner", { timeout: 5000 }, () => { const { runner } = recordingRunner({}); const bridge = new MacOSContactsBridge({}, { runner }); expect(bridge).toBeInstanceOf(MacOSContactsBridge); }); it("constructor accepts no arguments", { timeout: 5000 }, () => { const bridge = new MacOSContactsBridge(); expect(bridge).toBeInstanceOf(MacOSContactsBridge); }); it("sync uses Date.now() as the default clock", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every person": multiFixture }); const before = Date.now(); const bridge = new MacOSContactsBridge({}, { runner }); const result = await bridge.sync(); const after = Date.now(); expect(result.syncedAt.getTime()).toBeGreaterThanOrEqual(before); expect(result.syncedAt.getTime()).toBeLessThanOrEqual(after); }); it("constructor rejects invalid timeoutMs", { timeout: 5000 }, () => { let caught: unknown; try { new MacOSContactsBridge({ timeoutMs: -1 }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(MacOSContactsError); expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.INVALID_INPUT" ); }); it("getAllContacts parses contacts from the runner", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every person": multiFixture }); const bridge = new MacOSContactsBridge({}, { runner }); const contacts = await bridge.getAllContacts(); expect(contacts).toHaveLength(2); expect(contacts[0]?.firstName).toBe("Alice"); }); it("getAllContacts returns empty array when runner returns empty stdout", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every person": "" }); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.getAllContacts()).toEqual([]); }); it("getAllContacts maps permission-denial errors", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("not authorized"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getAllContacts(); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(MacOSContactsError); expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED" ); }); it("getAllContacts maps generic errors to access-failed", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("boom"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getAllContacts(); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED" ); }); it("getAllContacts handles non-Error rejections from the runner", { timeout: 5000, }, async () => { const runner: AppleScriptRunner = { async run(): Promise { const failure: unknown = "literal"; throw failure; }, }; const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getAllContacts(); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED" ); expect((caught as MacOSContactsError).cause).toBeUndefined(); }); it("searchContacts parses contacts on a non-empty query", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "name contains": singleFixture, }); const bridge = new MacOSContactsBridge({}, { runner }); const contacts = await bridge.searchContacts("Alice"); expect(contacts).toHaveLength(1); }); it("searchContacts short-circuits on empty query", { timeout: 5000, }, async () => { const { runner, calls } = recordingRunner({}); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.searchContacts("")).toEqual([]); expect(await bridge.searchContacts(" ")).toEqual([]); expect(calls).toHaveLength(0); }); it("searchContacts attaches the query discriminant on failure", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("boom"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.searchContacts("Alice"); } catch (e) { caught = e; } expect((caught as MacOSContactsError).query).toBe("Alice"); }); it("getContactById returns the matching contact", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every person": multiFixture }); const bridge = new MacOSContactsBridge({}, { runner }); const c = await bridge.getContactById("c2"); expect(c?.firstName).toBe("Bob"); }); it("getContactById returns null when no contact matches", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every person": singleFixture }); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.getContactById("not-real")).toBeNull(); }); it("getContactById short-circuits on empty id", { timeout: 5000, }, async () => { const { runner, calls } = recordingRunner({}); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.getContactById("")).toBeNull(); expect(calls).toHaveLength(0); }); it("getContactById attaches the contactId discriminant on access failure", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("boom"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getContactById("c1"); } catch (e) { caught = e; } expect((caught as MacOSContactsError).contactId).toBe("c1"); }); it("getContactById records permission_denied outcome on permission failure", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("not authorized"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getContactById("c1"); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED" ); expect((caught as MacOSContactsError).contactId).toBe("c1"); }); it("getContactById rewraps MacOSContactsError with the contactId discriminant", { timeout: 5000, }, async () => { const runner: AppleScriptRunner = { async run(): Promise { throw new MacOSContactsError( "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED", "inner" ); }, }; const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getContactById("c1"); } catch (e) { caught = e; } expect((caught as MacOSContactsError).contactId).toBe("c1"); }); it("getGroups parses groups from the runner", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every group": groupsFixture }); const bridge = new MacOSContactsBridge({}, { runner }); const groups = await bridge.getGroups(); expect(groups).toHaveLength(2); expect(groups[1]?.memberCount).toBe(8); }); it("getGroups maps runner failures to access-failed", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("boom"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getGroups(); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED" ); }); it("getGroupMembers parses the named group", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "people of targetGroup": singleFixture, }); const bridge = new MacOSContactsBridge({}, { runner }); const members = await bridge.getGroupMembers("Family"); expect(members).toHaveLength(1); }); it("getGroupMembers short-circuits on empty group name", { timeout: 5000, }, async () => { const { runner, calls } = recordingRunner({}); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.getGroupMembers("")).toEqual([]); expect(await bridge.getGroupMembers(" ")).toEqual([]); expect(calls).toHaveLength(0); }); it("getGroupMembers attaches the groupName discriminant on failure", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("boom"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getGroupMembers("Family"); } catch (e) { caught = e; } expect((caught as MacOSContactsError).groupName).toBe("Family"); }); it("sync returns the contacts on success", { timeout: 5000 }, async () => { const { runner } = recordingRunner({ "every person": multiFixture }); const bridge = new MacOSContactsBridge({}, { runner }); const result = await bridge.sync(); expect(result.contacts).toHaveLength(2); expect(result.total).toBe(2); expect(result.errors).toEqual([]); }); it("sync captures errors instead of throwing", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("boom — generic failure"); const bridge = new MacOSContactsBridge({}, { runner }); const result = await bridge.sync(); expect(result.contacts).toEqual([]); expect(result.total).toBe(0); expect(result.errors).toHaveLength(1); expect(result.errors[0]?.code).toBe( "MACOS_CONTACTS.CONTACTS_ACCESS_FAILED" ); expect(result.errors[0]?.message).toContain("boom"); }); it("sync records the permission-denied code on permission failure", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("not authorized"); const bridge = new MacOSContactsBridge({}, { runner }); const result = await bridge.sync(); expect(result.errors[0]?.code).toBe( "MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED" ); }); it("sync surfaces a generic message for non-Error rejections", { timeout: 5000, }, async () => { const runner: AppleScriptRunner = { async run(): Promise { const failure: unknown = "literal"; throw failure; }, }; const bridge = new MacOSContactsBridge({}, { runner }); const result = await bridge.sync(); expect(result.errors[0]?.message).toContain( "Failed to invoke Contacts.app" ); }); it("sync uses the injected clock for syncedAt", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "every person": multiFixture }); const bridge = new MacOSContactsBridge( {}, { runner, clock: () => 1_700_000_000_000 } ); const result = await bridge.sync(); expect(result.syncedAt.getTime()).toBe(1_700_000_000_000); }); it("getAllContacts maps not-found patterns to CONTACTS_NOT_FOUND", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("can't get person id 123"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getAllContacts(); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_NOT_FOUND" ); }); it("getAllContacts maps syntax-error output to CONTACTS_PARSE_FAILED", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("syntax error in script"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getAllContacts(); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_PARSE_FAILED" ); }); it("getAllContacts maps missing-binary failures to CONTACTS_UNAVAILABLE", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("ENOENT no such file or directory"); const bridge = new MacOSContactsBridge({}, { runner }); let caught: unknown; try { await bridge.getAllContacts(); } catch (e) { caught = e; } expect((caught as MacOSContactsError).code).toBe( "MACOS_CONTACTS.CONTACTS_UNAVAILABLE" ); }); it("isAvailable returns true when the runner resolves", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "System Events": "Finder" }); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.isAvailable()).toBe(true); }); it("isAvailable returns false when the runner resolves with empty stdout", { timeout: 5000, }, async () => { const { runner } = recordingRunner({ "System Events": "" }); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.isAvailable()).toBe(false); }); it("isAvailable returns false when the runner rejects", { timeout: 5000, }, async () => { const runner = alwaysFailingRunner("command not found"); const bridge = new MacOSContactsBridge({}, { runner }); expect(await bridge.isAvailable()).toBe(false); }); });