Initial Commit
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
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<string, string | Error>): {
|
||||
runner: AppleScriptRunner;
|
||||
calls: string[];
|
||||
} {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
runner: {
|
||||
async run(script: string): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user