mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
261 lines
8.1 KiB
TypeScript
261 lines
8.1 KiB
TypeScript
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
import type { SeqtaTimetableLesson } from "./types";
|
|
|
|
jest.mock("@/config/googleCalendar", () => ({
|
|
isGoogleCalendarConfigured: jest.fn(() => true),
|
|
BSPLUS_GOOGLE_CALENDAR_EVENT_PROP: "bsplusSeqtaKey",
|
|
GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3",
|
|
}));
|
|
|
|
jest.mock("@/utils/verboseLog", () => ({
|
|
verboseLog: jest.fn(),
|
|
}));
|
|
|
|
jest.mock("@/seqta/utils/googleCalendar/storage", () => {
|
|
const actual = jest.requireActual<typeof import("@/seqta/utils/googleCalendar/storage")>(
|
|
"@/seqta/utils/googleCalendar/storage",
|
|
);
|
|
return {
|
|
...actual,
|
|
readGoogleCalendarState: jest.fn(),
|
|
writeGoogleCalendarState: jest.fn(async (patch: unknown) => patch),
|
|
};
|
|
});
|
|
|
|
jest.mock("@/seqta/utils/calendarSync/settings", () => ({
|
|
getSyncWeeksAhead: jest.fn(async () => 12),
|
|
}));
|
|
|
|
jest.mock("@/seqta/utils/calendarSync/remoteEvents", () => ({
|
|
upsertGoogleCalendarEvent: jest.fn(),
|
|
deleteGoogleCalendarEvent: jest.fn(),
|
|
listGoogleSyncedEvents: jest.fn(async () => []),
|
|
}));
|
|
|
|
import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage";
|
|
import {
|
|
deleteGoogleCalendarEvent,
|
|
listGoogleSyncedEvents,
|
|
upsertGoogleCalendarEvent,
|
|
} from "@/seqta/utils/calendarSync/remoteEvents";
|
|
import {
|
|
deleteSyncedEventsFromGoogleCalendar,
|
|
syncLessonsToGoogleCalendar,
|
|
} from "@/seqta/utils/calendarSync/syncEngine";
|
|
import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint";
|
|
import { lessonToGoogleEvent } from "@/seqta/utils/googleCalendar/eventMapper";
|
|
|
|
import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange";
|
|
|
|
const ORIGIN = "https://school.seqta.com.au";
|
|
const getAccessToken = async () => "test-token";
|
|
const syncDate = syncWindowRange(12).from;
|
|
|
|
const baseLesson: SeqtaTimetableLesson = {
|
|
date: syncDate,
|
|
from: "09:00:00",
|
|
until: "10:00:00",
|
|
description: "10 Mathematics",
|
|
staff: "Mr Smith",
|
|
room: "MA1",
|
|
code: "10MAT",
|
|
type: "class",
|
|
calendarid: 12345,
|
|
};
|
|
|
|
describe("syncLessonsToGoogleCalendar", () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
|
refreshToken: "refresh",
|
|
calendarId: "app-calendar-id",
|
|
eventMap: {
|
|
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-existing", date: syncDate },
|
|
[`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-stale", date: "2020-01-06" },
|
|
},
|
|
});
|
|
jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing");
|
|
jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined);
|
|
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([]);
|
|
});
|
|
|
|
it("updates existing events and removes stale tracked events on full sync", async () => {
|
|
const result = await syncLessonsToGoogleCalendar(
|
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
|
getAccessToken,
|
|
);
|
|
|
|
expect(listGoogleSyncedEvents).toHaveBeenCalled();
|
|
expect(deleteGoogleCalendarEvent).toHaveBeenCalled();
|
|
expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1);
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
created: 0,
|
|
updated: 1,
|
|
failed: 0,
|
|
});
|
|
});
|
|
|
|
it("creates events that are not yet tracked", async () => {
|
|
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
|
refreshToken: "refresh",
|
|
calendarId: "app-calendar-id",
|
|
eventMap: {},
|
|
});
|
|
jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-new");
|
|
|
|
const result = await syncLessonsToGoogleCalendar(
|
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
|
getAccessToken,
|
|
);
|
|
|
|
expect(deleteGoogleCalendarEvent).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
created: 1,
|
|
updated: 0,
|
|
deleted: 0,
|
|
});
|
|
});
|
|
|
|
it("recovers remote IDs when the local map is empty and skips unchanged", async () => {
|
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
const mapped = lessonToGoogleEvent(ORIGIN, baseLesson, timeZone);
|
|
expect(mapped).not.toBeNull();
|
|
const fp = eventFingerprint(mapped!);
|
|
|
|
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
|
refreshToken: "refresh",
|
|
calendarId: "app-calendar-id",
|
|
eventMap: {},
|
|
});
|
|
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([
|
|
{
|
|
seqtaKey: `${ORIGIN}:cal:12345`,
|
|
id: "recovered-id",
|
|
date: syncDate,
|
|
fingerprint: fp,
|
|
},
|
|
]);
|
|
|
|
const result = await syncLessonsToGoogleCalendar(
|
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
|
getAccessToken,
|
|
);
|
|
|
|
expect(upsertGoogleCalendarEvent).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
created: 0,
|
|
updated: 0,
|
|
skipped: 1,
|
|
});
|
|
});
|
|
|
|
it("deletes cancelled lessons that remain inside the sync window", async () => {
|
|
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
|
refreshToken: "refresh",
|
|
calendarId: "app-calendar-id",
|
|
eventMap: {
|
|
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "keep", date: syncDate },
|
|
[`${ORIGIN}::${ORIGIN}:cal:cancelled`]: { id: "gone", date: syncDate },
|
|
},
|
|
});
|
|
|
|
const result = await syncLessonsToGoogleCalendar(
|
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
|
getAccessToken,
|
|
);
|
|
|
|
expect(deleteGoogleCalendarEvent).toHaveBeenCalledWith(
|
|
"test-token",
|
|
"app-calendar-id",
|
|
"gone",
|
|
expect.any(Function),
|
|
);
|
|
expect(result.deleted).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("does not delete events during incremental sync", async () => {
|
|
const result = await syncLessonsToGoogleCalendar(
|
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "incremental" },
|
|
getAccessToken,
|
|
);
|
|
|
|
expect(deleteGoogleCalendarEvent).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
deleted: 0,
|
|
});
|
|
});
|
|
|
|
it("reports progress while syncing", async () => {
|
|
const progress: Array<{ phase: string; current: number; total: number }> = [];
|
|
await syncLessonsToGoogleCalendar(
|
|
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
|
getAccessToken,
|
|
{
|
|
onProgress: (entry) => progress.push(entry),
|
|
},
|
|
);
|
|
|
|
expect(progress.some((entry) => entry.phase === "upserting")).toBe(true);
|
|
expect(progress.at(-1)?.phase).toBe("done");
|
|
});
|
|
});
|
|
|
|
describe("deleteSyncedEventsFromGoogleCalendar", () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
|
refreshToken: "refresh",
|
|
calendarId: "app-calendar-id",
|
|
eventMap: {
|
|
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-1", date: "2026-06-27" },
|
|
[`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-2", date: "2026-06-28" },
|
|
"https://other.seqta.com.au::other:key": { id: "google-other", date: "2026-06-28" },
|
|
},
|
|
});
|
|
jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("deletes only events for the requested origin", async () => {
|
|
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
|
|
|
|
expect(listGoogleSyncedEvents).toHaveBeenCalled();
|
|
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
deleted: 2,
|
|
failed: 0,
|
|
});
|
|
});
|
|
|
|
it("also deletes remote orphans found by list", async () => {
|
|
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
|
refreshToken: "refresh",
|
|
calendarId: "app-calendar-id",
|
|
eventMap: {},
|
|
});
|
|
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([
|
|
{
|
|
seqtaKey: `${ORIGIN}:cal:1`,
|
|
id: "remote-a",
|
|
date: "2026-06-27",
|
|
fingerprint: "fp",
|
|
},
|
|
{
|
|
seqtaKey: "",
|
|
id: "orphan-b",
|
|
date: "2026-06-28",
|
|
fingerprint: "fp",
|
|
},
|
|
]);
|
|
|
|
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
|
|
|
|
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
|
|
expect(result).toMatchObject({ success: true, deleted: 2, failed: 0 });
|
|
});
|
|
});
|