mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
fix: fix minor bugs
This commit is contained in:
@@ -669,22 +669,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#snippet googleIcon()}
|
{#snippet googleIcon()}
|
||||||
<svg viewBox="0 0 24 24">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="-3 0 262 262"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
|
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||||
fill="#4285F4"
|
fill="#4285F4"
|
||||||
d="M22 12c0-.96-.08-1.88-.24-2.76H12v5.22h5.68c-.24 1.28-.96 2.44-2.04 3.18v2.64h3.3c1.92-1.76 3.06-4.36 3.06-7.28z"
|
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
|
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||||
fill="#34A853"
|
fill="#34A853"
|
||||||
d="M12 22c2.76 0 5.08-.92 6.78-2.5l-3.3-2.64c-.92.62-2.1.98-3.48.98-2.68 0-4.96-1.8-5.78-4.22H2.18v2.72A10 10 0 0 0 12 22z"
|
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
|
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||||
fill="#FBBC05"
|
fill="#FBBC05"
|
||||||
d="M6.22 13.62A5.98 5.98 0 0 1 5.82 12c0-.56.1-1.1.28-1.62V7.66H2.18A10 10 0 0 0 2 12c0 1.62.38 3.16 1.06 4.52l3.16-2.9z"
|
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
fill="#EA4335"
|
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||||
d="M12 5.38c1.5 0 2.84.52 3.9 1.54l2.92-2.92C17.08 2.34 14.76 1.2 12 1.2 7.54 1.2 3.72 3.94 2.18 7.66l4.04 3.14c.82-2.42 3.1-4.22 5.78-4.22z"
|
fill="#EB4335"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
} from "./eventFingerprint";
|
} from "./eventFingerprint";
|
||||||
import {
|
import {
|
||||||
buildLessonSyncResult,
|
buildLessonSyncResult,
|
||||||
|
collectOriginDeleteEntries,
|
||||||
entriesToPrune,
|
entriesToPrune,
|
||||||
formatLessonSyncResultMessage,
|
formatLessonSyncResultMessage,
|
||||||
|
mapPool,
|
||||||
mergeRemoteEventsIntoMap,
|
mergeRemoteEventsIntoMap,
|
||||||
reportSyncProgress,
|
reportSyncProgress,
|
||||||
upsertLessonEvents,
|
upsertLessonEvents,
|
||||||
@@ -166,6 +168,45 @@ describe("mergeRemoteEventsIntoMap", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("collectOriginDeleteEntries", () => {
|
||||||
|
const origin = "https://school.seqta.com.au";
|
||||||
|
const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`;
|
||||||
|
|
||||||
|
it("includes local map entries and matching remote events", () => {
|
||||||
|
const eventMap = {
|
||||||
|
[`${origin}::${origin}:cal:1`]: { id: "local-1", date: "2026-07-13" },
|
||||||
|
};
|
||||||
|
const entries = collectOriginDeleteEntries(
|
||||||
|
eventMap,
|
||||||
|
origin,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
seqtaKey: `${origin}:cal:2`,
|
||||||
|
id: "remote-2",
|
||||||
|
date: "2026-07-14",
|
||||||
|
fingerprint: "fp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seqtaKey: "",
|
||||||
|
id: "orphan-3",
|
||||||
|
date: "2026-07-15",
|
||||||
|
fingerprint: "fp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seqtaKey: "https://other.seqta.com.au:cal:9",
|
||||||
|
id: "other",
|
||||||
|
date: "2026-07-15",
|
||||||
|
fingerprint: "fp",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mapKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids = entries.map(([, id]) => id).sort();
|
||||||
|
expect(ids).toEqual(["local-1", "orphan-3", "remote-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatLessonSyncResultMessage", () => {
|
describe("formatLessonSyncResultMessage", () => {
|
||||||
it("includes unchanged counts", () => {
|
it("includes unchanged counts", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -222,4 +263,61 @@ describe("upsertLessonEvents skip unchanged", () => {
|
|||||||
expect(upsert).not.toHaveBeenCalled();
|
expect(upsert).not.toHaveBeenCalled();
|
||||||
expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 });
|
expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs upserts concurrently", async () => {
|
||||||
|
const origin = "https://school.seqta.com.au";
|
||||||
|
let inFlight = 0;
|
||||||
|
let maxInFlight = 0;
|
||||||
|
const events = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
seqtaKey: `${origin}:cal:${i}`,
|
||||||
|
summary: `Class ${i}`,
|
||||||
|
description: "Synced by BetterSEQTA+",
|
||||||
|
startDateTime: "2026-07-13T09:00:00",
|
||||||
|
endDateTime: "2026-07-13T10:00:00",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const upsert = jest.fn(async () => {
|
||||||
|
inFlight += 1;
|
||||||
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
inFlight -= 1;
|
||||||
|
return `id-${Math.random()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await upsertLessonEvents({
|
||||||
|
events,
|
||||||
|
eventMap: {},
|
||||||
|
origin,
|
||||||
|
staleEntryCount: 0,
|
||||||
|
totalSteps: events.length,
|
||||||
|
lastSyncAt: Date.now(),
|
||||||
|
initialFailed: 0,
|
||||||
|
getAccessToken: async () => "token",
|
||||||
|
mapKey: (o, k) => `${o}::${k}`,
|
||||||
|
upsert,
|
||||||
|
writeState: async () => undefined,
|
||||||
|
logLabel: "Test",
|
||||||
|
concurrency: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.created).toBe(6);
|
||||||
|
expect(upsert).toHaveBeenCalledTimes(6);
|
||||||
|
expect(maxInFlight).toBeGreaterThan(1);
|
||||||
|
expect(maxInFlight).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapPool", () => {
|
||||||
|
it("limits concurrency", async () => {
|
||||||
|
let inFlight = 0;
|
||||||
|
let maxInFlight = 0;
|
||||||
|
await mapPool([1, 2, 3, 4, 5], 2, async () => {
|
||||||
|
inFlight += 1;
|
||||||
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
inFlight -= 1;
|
||||||
|
});
|
||||||
|
expect(maxInFlight).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,34 @@ import type {
|
|||||||
|
|
||||||
export const EVENT_MAP_PERSIST_EVERY = 10;
|
export const EVENT_MAP_PERSIST_EVERY = 10;
|
||||||
|
|
||||||
|
/** Max concurrent Google/Outlook create/update/delete requests during sync. */
|
||||||
|
export const SYNC_CONCURRENCY = 2;
|
||||||
|
|
||||||
|
/** Same cap for deletes to avoid provider rate limits. */
|
||||||
|
export const SYNC_DELETE_CONCURRENCY = 2;
|
||||||
|
|
||||||
|
/** Run async work over items with a fixed concurrency pool. */
|
||||||
|
export async function mapPool<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
concurrency: number,
|
||||||
|
worker: (item: T, index: number) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const limit = Math.max(1, Math.min(concurrency, items.length));
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function runWorker(): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
const index = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
if (index >= items.length) return;
|
||||||
|
await worker(items[index], index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
||||||
|
}
|
||||||
|
|
||||||
export type MappedLessonEvent = {
|
export type MappedLessonEvent = {
|
||||||
seqtaKey: string;
|
seqtaKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -104,6 +132,41 @@ export function mergeRemoteEventsIntoMap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a deduped delete list from local map + remote events for an origin.
|
||||||
|
* Unkeyed remote events (empty seqtaKey) are included as orphan removals.
|
||||||
|
*/
|
||||||
|
export function collectOriginDeleteEntries(
|
||||||
|
eventMap: EventMapRecord,
|
||||||
|
origin: string,
|
||||||
|
remoteEvents: RemoteSyncedEvent[],
|
||||||
|
mapKey: (origin: string, seqtaKey: string) => string,
|
||||||
|
): Array<[string, string]> {
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const [key, id] of originEventMapEntries(eventMap, origin)) {
|
||||||
|
byId.set(id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const remote of remoteEvents) {
|
||||||
|
if (remote.seqtaKey && !remote.seqtaKey.startsWith(origin)) continue;
|
||||||
|
if (byId.has(remote.id)) continue;
|
||||||
|
const key = remote.seqtaKey
|
||||||
|
? mapKey(origin, remote.seqtaKey)
|
||||||
|
: `${origin}::__orphan__:${remote.id}`;
|
||||||
|
byId.set(remote.id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byId.entries()).map(([id, key]) => [key, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOriginEventMapEntries(eventMap: EventMapRecord, origin: string): void {
|
||||||
|
const prefix = `${origin}::`;
|
||||||
|
for (const key of Object.keys(eventMap)) {
|
||||||
|
if (key.startsWith(prefix)) delete eventMap[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function reconcileRangeForMode(
|
export function reconcileRangeForMode(
|
||||||
mode: "full" | "incremental",
|
mode: "full" | "incremental",
|
||||||
weeksAhead: number,
|
weeksAhead: number,
|
||||||
@@ -184,6 +247,7 @@ export async function deleteTrackedLessonEvents(
|
|||||||
progressOffset?: number;
|
progressOffset?: number;
|
||||||
progressTotal?: number;
|
progressTotal?: number;
|
||||||
logLabel: string;
|
logLabel: string;
|
||||||
|
concurrency?: number;
|
||||||
},
|
},
|
||||||
): Promise<{ deleted: number; failed: number }> {
|
): Promise<{ deleted: number; failed: number }> {
|
||||||
if (entries.length === 0) return { deleted: 0, failed: 0 };
|
if (entries.length === 0) return { deleted: 0, failed: 0 };
|
||||||
@@ -194,18 +258,22 @@ export async function deleteTrackedLessonEvents(
|
|||||||
progressOffset = 0,
|
progressOffset = 0,
|
||||||
progressTotal = 0,
|
progressTotal = 0,
|
||||||
logLabel,
|
logLabel,
|
||||||
|
concurrency = SYNC_DELETE_CONCURRENCY,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let accessToken = await getAccessToken();
|
let accessToken = await getAccessToken();
|
||||||
let deleted = 0;
|
const refreshAccessToken = async () => {
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const [mapKey, eventId] of entries) {
|
|
||||||
try {
|
|
||||||
await deleteEvent(accessToken, eventId, async () => {
|
|
||||||
accessToken = await getAccessToken();
|
accessToken = await getAccessToken();
|
||||||
return accessToken;
|
return accessToken;
|
||||||
});
|
};
|
||||||
|
let deleted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let completed = 0;
|
||||||
|
let persistChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
await mapPool(entries, concurrency, async ([mapKey, eventId]) => {
|
||||||
|
try {
|
||||||
|
await deleteEvent(accessToken, eventId, refreshAccessToken);
|
||||||
delete eventMap[mapKey];
|
delete eventMap[mapKey];
|
||||||
deleted += 1;
|
deleted += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -213,16 +281,22 @@ export async function deleteTrackedLessonEvents(
|
|||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completed += 1;
|
||||||
reportSyncProgress(onProgress, {
|
reportSyncProgress(onProgress, {
|
||||||
phase: "deleting",
|
phase: "deleting",
|
||||||
current: progressOffset + deleted + failed,
|
current: progressOffset + completed,
|
||||||
total: progressTotal,
|
total: progressTotal,
|
||||||
message: `Removing old events (${deleted + failed}/${entries.length})…`,
|
message: `Removing old events (${completed}/${entries.length})…`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (persistProgress && (deleted + failed) % EVENT_MAP_PERSIST_EVERY === 0) {
|
if (persistProgress && completed % EVENT_MAP_PERSIST_EVERY === 0) {
|
||||||
await writeState({ eventMap });
|
persistChain = persistChain.then(() => writeState({ eventMap }));
|
||||||
|
await persistChain;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (persistProgress) {
|
||||||
|
await persistChain;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deleted, failed };
|
return { deleted, failed };
|
||||||
@@ -276,6 +350,7 @@ type UpsertLessonEventsParams<TEvent extends MappedLessonEvent> = {
|
|||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
onProgress?: GoogleCalendarSyncOptions["onProgress"];
|
onProgress?: GoogleCalendarSyncOptions["onProgress"];
|
||||||
logLabel: string;
|
logLabel: string;
|
||||||
|
concurrency?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||||
@@ -301,38 +376,38 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
|||||||
writeState,
|
writeState,
|
||||||
onProgress,
|
onProgress,
|
||||||
logLabel,
|
logLabel,
|
||||||
|
concurrency = SYNC_CONCURRENCY,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
let accessToken = await getAccessToken();
|
let accessToken = await getAccessToken();
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
accessToken = await getAccessToken();
|
||||||
|
return accessToken;
|
||||||
|
};
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let failed = initialFailed;
|
let failed = initialFailed;
|
||||||
|
let completed = 0;
|
||||||
|
let persistChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
for (let i = 0; i < events.length; i++) {
|
const persistState = () => {
|
||||||
const event = events[i];
|
persistChain = persistChain.then(() =>
|
||||||
|
writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin }),
|
||||||
|
);
|
||||||
|
return persistChain;
|
||||||
|
};
|
||||||
|
|
||||||
|
await mapPool(events, concurrency, async (event) => {
|
||||||
const key = mapKey(origin, event.seqtaKey);
|
const key = mapKey(origin, event.seqtaKey);
|
||||||
const existingId = getStoredEventId(eventMap[key]);
|
const existingId = getStoredEventId(eventMap[key]);
|
||||||
const desiredFingerprint = eventFingerprint(event);
|
const desiredFingerprint = eventFingerprint(event);
|
||||||
const progressCurrent = staleEntryCount + i + 1;
|
|
||||||
const progressMessage = `Syncing events (${i + 1}/${events.length})…`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existingId && getStoredFingerprint(eventMap[key]) === desiredFingerprint) {
|
if (existingId && getStoredFingerprint(eventMap[key]) === desiredFingerprint) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
reportSyncProgress(onProgress, {
|
} else {
|
||||||
phase: "upserting",
|
const remoteId = await upsert(accessToken, existingId, event, refreshAccessToken);
|
||||||
current: progressCurrent,
|
|
||||||
total: totalSteps,
|
|
||||||
message: progressMessage,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteId = await upsert(accessToken, existingId, event, async () => {
|
|
||||||
accessToken = await getAccessToken();
|
|
||||||
return accessToken;
|
|
||||||
});
|
|
||||||
if (existingId) updated += 1;
|
if (existingId) updated += 1;
|
||||||
else created += 1;
|
else created += 1;
|
||||||
eventMap[key] = {
|
eventMap[key] = {
|
||||||
@@ -340,21 +415,27 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
|||||||
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
|
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
|
||||||
fingerprint: desiredFingerprint,
|
fingerprint: desiredFingerprint,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) {
|
|
||||||
await writeState({ eventMap, lastSyncAt, lastSyncOrigin: origin });
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err);
|
verboseLog(`[BetterSEQTA+] ${logLabel} event sync failed:`, err);
|
||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completed += 1;
|
||||||
reportSyncProgress(onProgress, {
|
reportSyncProgress(onProgress, {
|
||||||
phase: "upserting",
|
phase: "upserting",
|
||||||
current: progressCurrent,
|
current: staleEntryCount + completed,
|
||||||
total: totalSteps,
|
total: totalSteps,
|
||||||
message: progressMessage,
|
message: `Syncing events (${completed}/${events.length})…`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (completed % EVENT_MAP_PERSIST_EVERY === 0) {
|
||||||
|
await persistState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
await persistState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, updated, skipped, failed, accessToken };
|
return { created, updated, skipped, failed, accessToken };
|
||||||
|
|||||||
@@ -16,21 +16,48 @@ export type RemoteSyncedEvent = {
|
|||||||
date: string;
|
date: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ListSyncedEventsOptions = {
|
||||||
|
/** Include category/calendar events that are missing a seqta key (legacy orphans). */
|
||||||
|
includeUnkeyed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RETRIES = 4;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableStatus(status: number): boolean {
|
||||||
|
return status === 429 || status === 500 || status === 502 || status === 503 || status === 403;
|
||||||
|
}
|
||||||
|
|
||||||
async function authorizedFetch(
|
async function authorizedFetch(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
refreshAccessToken?: () => Promise<string>,
|
refreshAccessToken?: () => Promise<string>,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
let token = accessToken;
|
||||||
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, ...init.headers },
|
headers: { Authorization: `Bearer ${token}`, ...init.headers },
|
||||||
});
|
});
|
||||||
if (res.status === 401 && refreshAccessToken) {
|
if (res.status === 401 && refreshAccessToken) {
|
||||||
return authorizedFetch(await refreshAccessToken(), url, init);
|
token = await refreshAccessToken();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isRetryableStatus(res.status) && attempt < MAX_RETRIES - 1) {
|
||||||
|
await sleep(250 * 2 ** attempt + Math.floor(Math.random() * 100));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, ...init.headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertRemoteEvent(
|
async function upsertRemoteEvent(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -182,9 +209,13 @@ type GoogleListItem = {
|
|||||||
extendedProperties?: { private?: Record<string, string> };
|
extendedProperties?: { private?: Record<string, string> };
|
||||||
};
|
};
|
||||||
|
|
||||||
function googleItemToRemote(item: GoogleListItem): RemoteSyncedEvent | null {
|
function googleItemToRemote(
|
||||||
const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP];
|
item: GoogleListItem,
|
||||||
if (!item.id || !seqtaKey) return null;
|
includeUnkeyed: boolean,
|
||||||
|
): RemoteSyncedEvent | null {
|
||||||
|
if (!item.id) return null;
|
||||||
|
const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP] ?? "";
|
||||||
|
if (!seqtaKey && !includeUnkeyed) return null;
|
||||||
const startDateTime = item.start?.dateTime ?? (item.start?.date ? `${item.start.date}T00:00:00` : "");
|
const startDateTime = item.start?.dateTime ?? (item.start?.date ? `${item.start.date}T00:00:00` : "");
|
||||||
const endDateTime = item.end?.dateTime ?? (item.end?.date ? `${item.end.date}T00:00:00` : "");
|
const endDateTime = item.end?.dateTime ?? (item.end?.date ? `${item.end.date}T00:00:00` : "");
|
||||||
const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC";
|
const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC";
|
||||||
@@ -208,7 +239,9 @@ export async function listGoogleSyncedEvents(
|
|||||||
calendarId: string,
|
calendarId: string,
|
||||||
range: SyncDateRange,
|
range: SyncDateRange,
|
||||||
refreshAccessToken?: () => Promise<string>,
|
refreshAccessToken?: () => Promise<string>,
|
||||||
|
options: ListSyncedEventsOptions = {},
|
||||||
): Promise<RemoteSyncedEvent[]> {
|
): Promise<RemoteSyncedEvent[]> {
|
||||||
|
const includeUnkeyed = options.includeUnkeyed === true;
|
||||||
const encodedCalendar = encodeURIComponent(calendarId);
|
const encodedCalendar = encodeURIComponent(calendarId);
|
||||||
const out: RemoteSyncedEvent[] = [];
|
const out: RemoteSyncedEvent[] = [];
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
@@ -241,7 +274,7 @@ export async function listGoogleSyncedEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of json.items ?? []) {
|
for (const item of json.items ?? []) {
|
||||||
const mapped = googleItemToRemote(item);
|
const mapped = googleItemToRemote(item, includeUnkeyed);
|
||||||
if (mapped) out.push(mapped);
|
if (mapped) out.push(mapped);
|
||||||
}
|
}
|
||||||
pageToken = json.nextPageToken;
|
pageToken = json.nextPageToken;
|
||||||
@@ -260,13 +293,16 @@ type OutlookListItem = {
|
|||||||
categories?: string[];
|
categories?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function outlookItemToRemote(item: OutlookListItem): RemoteSyncedEvent | null {
|
function outlookItemToRemote(
|
||||||
|
item: OutlookListItem,
|
||||||
|
includeUnkeyed: boolean,
|
||||||
|
): RemoteSyncedEvent | null {
|
||||||
if (!item.id) return null;
|
if (!item.id) return null;
|
||||||
const categories = item.categories ?? [];
|
const categories = item.categories ?? [];
|
||||||
if (!categories.includes(BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY)) return null;
|
if (!categories.includes(BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY)) return null;
|
||||||
const bodyContent = item.body?.content ?? "";
|
const bodyContent = item.body?.content ?? "";
|
||||||
const seqtaKey = parseOutlookSeqtaKey(bodyContent);
|
const seqtaKey = parseOutlookSeqtaKey(bodyContent) ?? "";
|
||||||
if (!seqtaKey) return null;
|
if (!seqtaKey && !includeUnkeyed) return null;
|
||||||
|
|
||||||
const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, "");
|
const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, "");
|
||||||
const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, "");
|
const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, "");
|
||||||
@@ -300,7 +336,9 @@ export async function listOutlookSyncedEvents(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
range: SyncDateRange,
|
range: SyncDateRange,
|
||||||
refreshAccessToken?: () => Promise<string>,
|
refreshAccessToken?: () => Promise<string>,
|
||||||
|
options: ListSyncedEventsOptions = {},
|
||||||
): Promise<RemoteSyncedEvent[]> {
|
): Promise<RemoteSyncedEvent[]> {
|
||||||
|
const includeUnkeyed = options.includeUnkeyed === true;
|
||||||
const out: RemoteSyncedEvent[] = [];
|
const out: RemoteSyncedEvent[] = [];
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDateTime: toRfc3339Start(range.from),
|
startDateTime: toRfc3339Start(range.from),
|
||||||
@@ -332,7 +370,7 @@ export async function listOutlookSyncedEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of json.value ?? []) {
|
for (const item of json.value ?? []) {
|
||||||
const mapped = outlookItemToRemote(item);
|
const mapped = outlookItemToRemote(item, includeUnkeyed);
|
||||||
if (mapped) out.push(mapped);
|
if (mapped) out.push(mapped);
|
||||||
}
|
}
|
||||||
nextUrl = json["@odata.nextLink"];
|
nextUrl = json["@odata.nextLink"];
|
||||||
|
|||||||
@@ -6,33 +6,18 @@ import type { EventMapRecord } from "@/seqta/utils/calendarSync/eventMap";
|
|||||||
import {
|
import {
|
||||||
buildDeleteSyncResult,
|
buildDeleteSyncResult,
|
||||||
buildLessonSyncResult,
|
buildLessonSyncResult,
|
||||||
|
clearOriginEventMapEntries,
|
||||||
|
collectOriginDeleteEntries,
|
||||||
deleteTrackedLessonEvents,
|
deleteTrackedLessonEvents,
|
||||||
emptyLessonsSyncResult,
|
emptyLessonsSyncResult,
|
||||||
entriesToPrune,
|
entriesToPrune,
|
||||||
mergeRemoteEventsIntoMap,
|
mergeRemoteEventsIntoMap,
|
||||||
notConfiguredSyncResult,
|
notConfiguredSyncResult,
|
||||||
notConnectedSyncResult,
|
notConnectedSyncResult,
|
||||||
originEventMapEntries,
|
|
||||||
reconcileRangeForMode,
|
reconcileRangeForMode,
|
||||||
reportSyncProgress,
|
reportSyncProgress,
|
||||||
upsertLessonEvents,
|
upsertLessonEvents,
|
||||||
} from "@/seqta/utils/calendarSync/lessonSyncShared";
|
} from "@/seqta/utils/calendarSync/lessonSyncShared";
|
||||||
import {
|
|
||||||
googleApiEventBody,
|
|
||||||
mapLessonsToGoogleEvents,
|
|
||||||
outlookGraphEventBody,
|
|
||||||
} from "@/seqta/utils/googleCalendar/eventMapper";
|
|
||||||
import {
|
|
||||||
readGoogleCalendarState,
|
|
||||||
writeGoogleCalendarState,
|
|
||||||
} from "@/seqta/utils/googleCalendar/storage";
|
|
||||||
import type {
|
|
||||||
GoogleCalendarDeleteResult,
|
|
||||||
GoogleCalendarEventInput,
|
|
||||||
GoogleCalendarSyncOptions,
|
|
||||||
GoogleCalendarSyncRequest,
|
|
||||||
GoogleCalendarSyncResult,
|
|
||||||
} from "@/seqta/utils/googleCalendar/types";
|
|
||||||
import {
|
import {
|
||||||
deleteGoogleCalendarEvent,
|
deleteGoogleCalendarEvent,
|
||||||
deleteOutlookCalendarEvent,
|
deleteOutlookCalendarEvent,
|
||||||
@@ -40,14 +25,32 @@ import {
|
|||||||
listOutlookSyncedEvents,
|
listOutlookSyncedEvents,
|
||||||
upsertGoogleCalendarEvent,
|
upsertGoogleCalendarEvent,
|
||||||
upsertOutlookCalendarEvent,
|
upsertOutlookCalendarEvent,
|
||||||
|
type ListSyncedEventsOptions,
|
||||||
type RemoteSyncedEvent,
|
type RemoteSyncedEvent,
|
||||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
} from "@/seqta/utils/calendarSync/remoteEvents";
|
||||||
|
import {
|
||||||
|
googleApiEventBody,
|
||||||
|
mapLessonsToGoogleEvents,
|
||||||
|
outlookGraphEventBody,
|
||||||
|
} from "@/seqta/utils/googleCalendar/eventMapper";
|
||||||
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
|
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
|
||||||
|
import {
|
||||||
|
readGoogleCalendarState,
|
||||||
|
writeGoogleCalendarState,
|
||||||
|
} from "@/seqta/utils/googleCalendar/storage";
|
||||||
import {
|
import {
|
||||||
syncWindowRange,
|
syncWindowRange,
|
||||||
trailingWeekRange,
|
trailingWeekRange,
|
||||||
|
wideCleanupRange,
|
||||||
type SyncDateRange,
|
type SyncDateRange,
|
||||||
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||||
|
import type {
|
||||||
|
GoogleCalendarDeleteResult,
|
||||||
|
GoogleCalendarEventInput,
|
||||||
|
GoogleCalendarSyncOptions,
|
||||||
|
GoogleCalendarSyncRequest,
|
||||||
|
GoogleCalendarSyncResult,
|
||||||
|
} from "@/seqta/utils/googleCalendar/types";
|
||||||
import {
|
import {
|
||||||
readOutlookCalendarState,
|
readOutlookCalendarState,
|
||||||
writeOutlookCalendarState,
|
writeOutlookCalendarState,
|
||||||
@@ -86,6 +89,7 @@ export type CalendarLessonSyncProvider = {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
range: SyncDateRange,
|
range: SyncDateRange,
|
||||||
refreshAccessToken: () => Promise<string>,
|
refreshAccessToken: () => Promise<string>,
|
||||||
|
options?: ListSyncedEventsOptions,
|
||||||
) => Promise<RemoteSyncedEvent[]>;
|
) => Promise<RemoteSyncedEvent[]>;
|
||||||
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
|
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@@ -121,12 +125,13 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
|
|||||||
body,
|
body,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
),
|
),
|
||||||
listSyncedEvents: async (accessToken, range, refreshAccessToken) =>
|
listSyncedEvents: async (accessToken, range, refreshAccessToken, options) =>
|
||||||
listGoogleSyncedEvents(
|
listGoogleSyncedEvents(
|
||||||
accessToken,
|
accessToken,
|
||||||
await getOrProvisionGoogleCalendarId(accessToken),
|
await getOrProvisionGoogleCalendarId(accessToken),
|
||||||
range,
|
range,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
options,
|
||||||
),
|
),
|
||||||
toApiBody: googleApiEventBody,
|
toApiBody: googleApiEventBody,
|
||||||
};
|
};
|
||||||
@@ -142,8 +147,8 @@ export const outlookLessonSyncProvider: CalendarLessonSyncProvider = {
|
|||||||
deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken),
|
deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken),
|
||||||
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
|
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
|
||||||
upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken),
|
upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken),
|
||||||
listSyncedEvents: (accessToken, range, refreshAccessToken) =>
|
listSyncedEvents: (accessToken, range, refreshAccessToken, options) =>
|
||||||
listOutlookSyncedEvents(accessToken, range, refreshAccessToken),
|
listOutlookSyncedEvents(accessToken, range, refreshAccessToken, options),
|
||||||
toApiBody: outlookGraphEventBody,
|
toApiBody: outlookGraphEventBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,10 +173,6 @@ export async function syncLessonsToCalendar(
|
|||||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||||
const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone);
|
const events = mapLessonsToGoogleEvents(request.origin, request.lessons, timeZone);
|
||||||
|
|
||||||
if (events.length === 0 && mode === "full") {
|
|
||||||
return emptyLessonsSyncResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
reportSyncProgress(options.onProgress, {
|
reportSyncProgress(options.onProgress, {
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -204,7 +205,7 @@ export async function syncLessonsToCalendar(
|
|||||||
|
|
||||||
const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey)));
|
const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey)));
|
||||||
const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys);
|
const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys);
|
||||||
const totalSteps = staleEntries.length + events.length;
|
const totalSteps = Math.max(staleEntries.length + events.length, 1);
|
||||||
const lastSyncAt = Date.now();
|
const lastSyncAt = Date.now();
|
||||||
|
|
||||||
const staleResult = await deleteTrackedLessonEvents(
|
const staleResult = await deleteTrackedLessonEvents(
|
||||||
@@ -220,6 +221,24 @@ export async function syncLessonsToCalendar(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (events.length === 0 && mode === "full") {
|
||||||
|
await provider.writeState({
|
||||||
|
eventMap,
|
||||||
|
lastSyncAt,
|
||||||
|
lastSyncOrigin: request.origin,
|
||||||
|
});
|
||||||
|
reportSyncProgress(options.onProgress, {
|
||||||
|
phase: "done",
|
||||||
|
current: totalSteps,
|
||||||
|
total: totalSteps,
|
||||||
|
message: "Sync complete",
|
||||||
|
});
|
||||||
|
if (staleResult.deleted > 0) {
|
||||||
|
return buildLessonSyncResult(0, 0, staleResult.deleted, 0, staleResult.failed, lastSyncAt);
|
||||||
|
}
|
||||||
|
return emptyLessonsSyncResult();
|
||||||
|
}
|
||||||
|
|
||||||
const upsertResult = await upsertLessonEvents({
|
const upsertResult = await upsertLessonEvents({
|
||||||
events,
|
events,
|
||||||
eventMap,
|
eventMap,
|
||||||
@@ -282,19 +301,40 @@ export async function deleteSyncedEventsFromCalendar(
|
|||||||
return { success: false, configured: true, connected: false, error: provider.notConnectedError };
|
return { success: false, configured: true, connected: false, error: provider.notConnectedError };
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = originEventMapEntries(state.eventMap ?? {}, origin);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return { success: true, configured: true, connected: true, deleted: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
reportSyncProgress(options.onProgress, {
|
reportSyncProgress(options.onProgress, {
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
current: 0,
|
current: 0,
|
||||||
total: entries.length,
|
total: 1,
|
||||||
message: "Preparing removal…",
|
message: "Preparing removal…",
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventMap = { ...(state.eventMap ?? {}) };
|
const eventMap = { ...(state.eventMap ?? {}) };
|
||||||
|
let accessToken = await getAccessToken();
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
accessToken = await getAccessToken();
|
||||||
|
return accessToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remoteEvents: RemoteSyncedEvent[] = [];
|
||||||
|
try {
|
||||||
|
remoteEvents = await provider.listSyncedEvents(
|
||||||
|
accessToken,
|
||||||
|
wideCleanupRange(),
|
||||||
|
refreshAccessToken,
|
||||||
|
{ includeUnkeyed: true },
|
||||||
|
);
|
||||||
|
mergeRemoteEventsIntoMap(eventMap, origin, remoteEvents, eventMapKey);
|
||||||
|
} catch (err) {
|
||||||
|
verboseLog(`[BetterSEQTA+] ${provider.label} cleanup list failed:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = collectOriginDeleteEntries(eventMap, origin, remoteEvents, eventMapKey);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
clearOriginEventMapEntries(eventMap, origin);
|
||||||
|
await provider.writeState({ eventMap });
|
||||||
|
return { success: true, configured: true, connected: true, deleted: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const { deleted, failed } = await deleteTrackedLessonEvents(
|
const { deleted, failed } = await deleteTrackedLessonEvents(
|
||||||
entries,
|
entries,
|
||||||
eventMap,
|
eventMap,
|
||||||
@@ -309,6 +349,10 @@ export async function deleteSyncedEventsFromCalendar(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only wipe remaining origin keys when every delete succeeded — keep failed IDs for retry.
|
||||||
|
if (failed === 0) {
|
||||||
|
clearOriginEventMapEntries(eventMap, origin);
|
||||||
|
}
|
||||||
await provider.writeState({ eventMap });
|
await provider.writeState({ eventMap });
|
||||||
|
|
||||||
reportSyncProgress(options.onProgress, {
|
reportSyncProgress(options.onProgress, {
|
||||||
|
|||||||
@@ -40,3 +40,13 @@ export function droppedWeekRange(weeksAhead = GOOGLE_CALENDAR_SYNC_WEEKS): SyncD
|
|||||||
export function isDateInRange(date: string, range: SyncDateRange): boolean {
|
export function isDateInRange(date: string, range: SyncDateRange): boolean {
|
||||||
return date >= range.from && date <= range.until;
|
return date >= range.from && date <= range.until;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wide range used when removing all synced events (covers past + future terms). */
|
||||||
|
export function wideCleanupRange(years = 3): SyncDateRange {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now);
|
||||||
|
from.setFullYear(from.getFullYear() - years);
|
||||||
|
const until = new Date(now);
|
||||||
|
until.setFullYear(until.getFullYear() + years);
|
||||||
|
return { from: toISODate(from), until: toISODate(until) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
|
|||||||
it("deletes only events for the requested origin", async () => {
|
it("deletes only events for the requested origin", async () => {
|
||||||
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
|
const result = await deleteSyncedEventsFromGoogleCalendar(ORIGIN, getAccessToken);
|
||||||
|
|
||||||
|
expect(listGoogleSyncedEvents).toHaveBeenCalled();
|
||||||
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
|
expect(deleteGoogleCalendarEvent).toHaveBeenCalledTimes(2);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -229,4 +230,31 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
|
|||||||
failed: 0,
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user