Adds the macOS photos-helper Swift CLI (PhotoKit listing with stable cursors, persistent change tokens, iCloud-aware original export with a path guard and SHA-256) plus XCTests for its PhotoKit-free core, and the Electron main-process side: a validated helper client, a resumable metadata push and chunked original upload against the media contract, and IPC handlers that stay unavailable off macOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { MediaIpcEvent, MediaIpcOptions } from "./media-ipc.js";
|
|
import {
|
|
MEDIA_PHOTOS_CHANNELS,
|
|
outcomeForError,
|
|
parseSyncRequest,
|
|
registerMediaIpc,
|
|
} from "./media-ipc.js";
|
|
import type { PhotosHelper } from "./media-photos-helper.js";
|
|
import { PhotosHelperError } from "./media-photos-helper.js";
|
|
import type { PhotosSyncResult } from "./media-photos-sync.js";
|
|
import { PhotosSyncError } from "./media-photos-sync.js";
|
|
|
|
type Listener = (event: MediaIpcEvent, ...args: unknown[]) => unknown;
|
|
|
|
const result: PhotosSyncResult = {
|
|
indexed: 2,
|
|
created: 2,
|
|
updated: 0,
|
|
unchanged: 0,
|
|
uploaded: 0,
|
|
fullListing: true,
|
|
};
|
|
|
|
function setup(overrides: Partial<MediaIpcOptions> = {}) {
|
|
const handlers = new Map<string, Listener>();
|
|
const helper = {
|
|
authorize: vi.fn(async () => "authorized" as const),
|
|
} as unknown as PhotosHelper;
|
|
const options: MediaIpcOptions = {
|
|
ipcMain: { handle: (channel, listener) => handlers.set(channel, listener) },
|
|
platform: "darwin",
|
|
helper,
|
|
isTrusted: () => true,
|
|
runSync: vi.fn(async () => result),
|
|
...overrides,
|
|
};
|
|
const registration = registerMediaIpc(options);
|
|
const sent: [string, unknown][] = [];
|
|
const event: MediaIpcEvent = {
|
|
sender: { send: (channel, payload) => sent.push([channel, payload]), isDestroyed: () => false },
|
|
};
|
|
const call = (channel: string, ...args: unknown[]) => handlers.get(channel)!(event, ...args);
|
|
return { handlers, helper, options, registration, call, sent };
|
|
}
|
|
|
|
describe("media IPC on platforms without Apple Photos", () => {
|
|
it.each(["linux", "win32"])(
|
|
"reports %s as unavailable and rejects sync and cancel",
|
|
async (platform) => {
|
|
const { call, options, handlers } = setup({ platform });
|
|
expect([...handlers.keys()].sort()).toEqual(
|
|
[
|
|
MEDIA_PHOTOS_CHANNELS.cancel,
|
|
MEDIA_PHOTOS_CHANNELS.status,
|
|
MEDIA_PHOTOS_CHANNELS.sync,
|
|
].sort(),
|
|
);
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
|
|
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: null })).toThrow(/macOS/);
|
|
expect(() => call(MEDIA_PHOTOS_CHANNELS.cancel)).toThrow(/macOS/);
|
|
expect(options.runSync).not.toHaveBeenCalled();
|
|
expect(options.helper!.authorize).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("treats a macOS build without the helper as unavailable", async () => {
|
|
const { call } = setup({ helper: null });
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
|
|
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, {})).toThrow(/macOS/);
|
|
});
|
|
|
|
it("treats a helper that cannot start as unavailable", async () => {
|
|
const { call, helper } = setup();
|
|
vi.mocked(helper.authorize).mockRejectedValueOnce(new PhotosHelperError("unavailable", "x"));
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
|
|
});
|
|
});
|
|
|
|
describe("media IPC on macOS", () => {
|
|
it("reads status without prompting", async () => {
|
|
const { call, helper } = setup();
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({
|
|
available: true,
|
|
authorization: "authorized",
|
|
syncing: false,
|
|
});
|
|
expect(helper.authorize).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("runs one sync at a time, forwards progress and resolves with the outcome", async () => {
|
|
let finish: (value: PhotosSyncResult) => void = () => {};
|
|
const runSync = vi.fn(
|
|
({ onProgress }: Parameters<MediaIpcOptions["runSync"]>[0]) =>
|
|
new Promise<PhotosSyncResult>((resolve) => {
|
|
onProgress({ phase: "indexing", indexed: 1, uploadsDone: 0, uploadsTotal: 0 });
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const { call, sent } = setup({ runSync });
|
|
const first = call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: "space_1" });
|
|
const second = call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: "space_1" });
|
|
expect(second).toBe(first);
|
|
expect(runSync).toHaveBeenCalledTimes(1);
|
|
expect(runSync.mock.calls[0]?.[0].spaceId).toBe("space_1");
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toMatchObject({ syncing: true });
|
|
expect(sent).toEqual([
|
|
[
|
|
MEDIA_PHOTOS_CHANNELS.progress,
|
|
{ phase: "indexing", indexed: 1, uploadsDone: 0, uploadsTotal: 0 },
|
|
],
|
|
]);
|
|
finish(result);
|
|
await expect(first).resolves.toEqual({ ok: true, result });
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toMatchObject({ syncing: false });
|
|
});
|
|
|
|
it("cancels the running sync", async () => {
|
|
const runSync = vi.fn(
|
|
({ signal }: Parameters<MediaIpcOptions["runSync"]>[0]) =>
|
|
new Promise<PhotosSyncResult>((_resolve, reject) => {
|
|
signal.addEventListener("abort", () =>
|
|
reject(new PhotosSyncError("cancelled", "Sync cancelled.")),
|
|
);
|
|
}),
|
|
);
|
|
const { call } = setup({ runSync });
|
|
const running = call(MEDIA_PHOTOS_CHANNELS.sync, {});
|
|
await call(MEDIA_PHOTOS_CHANNELS.cancel);
|
|
await expect(running).resolves.toEqual({
|
|
ok: false,
|
|
code: "cancelled",
|
|
message: "Sync cancelled.",
|
|
});
|
|
});
|
|
|
|
it("rejects untrusted senders and malformed requests", async () => {
|
|
const { call } = setup({ isTrusted: () => false });
|
|
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, {})).toThrow("Not allowed.");
|
|
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).rejects.toThrow("Not allowed.");
|
|
expect(() => parseSyncRequest(null)).toThrow();
|
|
expect(() => parseSyncRequest({ spaceId: "../x" })).toThrow();
|
|
expect(() => parseSyncRequest({ spaceId: 4 })).toThrow();
|
|
expect(parseSyncRequest({})).toEqual({ spaceId: null });
|
|
});
|
|
|
|
it("never forwards unexpected error text to the renderer", () => {
|
|
expect(outcomeForError(new Error("/Users/example/secret path"))).toEqual({
|
|
ok: false,
|
|
code: "failed",
|
|
message: "Apple Photos sync failed.",
|
|
});
|
|
expect(
|
|
outcomeForError(
|
|
new PhotosHelperError("not_authorized", "Photos access has not been granted."),
|
|
),
|
|
).toMatchObject({
|
|
code: "not_authorized",
|
|
});
|
|
});
|
|
});
|