school-schedule-sync
Version:
Synchronization between JSON schedule and Google Calendar
248 lines (190 loc) • 7.76 kB
text/typescript
import fs from 'fs';
import { getAPI } from '@anmiles/google-api-wrapper';
import { validate } from '@anmiles/zod-tools';
import type GoogleApis from 'googleapis';
// eslint-disable-next-line camelcase
import type { calendar_v3, MethodOptions } from 'googleapis/build/src/apis/calendar';
import { calendar } from 'googleapis/build/src/apis/calendar';
import '@anmiles/jest-extensions';
import mockFs from 'mock-fs';
import { sync } from '../sync';
import { scheduleSchema } from '../types';
import type { LessonCalendar, Schedule } from '../types';
import { getSampleScheduleFile, getScheduleFile } from '../utils/paths';
jest.mock('@anmiles/google-api-wrapper');
jest.mock('@anmiles/logger');
jest.mock('googleapis/build/src/apis/calendar');
type Calendar = {
id: string;
summary: string;
};
type Event = {
id?: string;
};
const profile = 'username';
const calendarApis = {
calendarList: 'calendarList',
events : 'events',
} as const;
function mockGetItems(selectAPI: (api: typeof calendarApis)=> typeof calendarApis[keyof typeof calendarApis]): typeof calendars | typeof events {
switch (selectAPI(calendarApis)) {
case calendarApis.calendarList: return calendars;
case calendarApis.events: return events;
}
}
const getItems = jest.mocked(jest.fn().mockImplementation(mockGetItems));
const api = {
events: {
insert: jest.fn(),
delete: jest.fn(),
},
};
// eslint-disable-next-line @typescript-eslint/require-await -- allow partial mock
const getAPIMock = jest.fn().mockImplementation(async () => ({ getItems, api }));
const auth = { kind: 'auth' };
let calendars: Calendar[];
let events: Event[];
let selectedCalendar: Calendar;
jest.mocked(getAPI).mockImplementation((...args: unknown[]) => getAPIMock(...args));
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
jest.mocked(calendar).mockReturnValue(calendarApis as unknown as GoogleApis.calendar_v3.Calendar);
const scheduleJSON = fs.readJSON(getSampleScheduleFile());
const schedule = validate(scheduleJSON, scheduleSchema);
beforeAll(() => {
jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
locale : 'en-US',
calendar : 'gregory',
numberingSystem: 'latn',
timeZone : 'UTC',
timeZoneName : 'short',
});
});
beforeEach(() => {
mockFs({
[getScheduleFile()]: JSON.stringify(schedule),
});
calendars = [
{ id: 'lessons-1th', summary: 'First grade lessons' },
{ id: 'lessons-hs', summary: 'High school lessons' },
{ id: 'sections-1th', summary: 'First grade sections' },
];
events = [
{ id: 'id1' },
{ },
{ id: 'id3' },
];
selectedCalendar = calendars[0]!;
});
afterAll(() => {
mockFs.restore();
});
const fullScopes = [
'https://www.googleapis.com/auth/calendar.calendars.readonly',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
'https://www.googleapis.com/auth/calendar.events.owned',
];
describe('src/lib/sync', () => {
describe('sync', () => {
it('should get calendar API', async () => {
await sync(profile);
expect(getAPIMock).toHaveBeenCalledWith(expect.toBeFunction([ auth ], calendarApis), profile, { temporary: true, scopes: fullScopes });
expect(calendar).toHaveBeenCalledWith({ version: 'v3', auth });
});
it('should get all calendars without showing progress', async () => {
await sync(profile);
expect(getItems).toHaveBeenCalledWith(expect.anything(), {}, { hideProgress: true });
expect(getItems.mock.calls[0]?.[0](calendarApis)).toEqual(calendarApis.calendarList);
});
it('should throw if there are no available calendars', async () => {
calendars = [];
const promise = async (): Promise<unknown> => sync(profile);
await expect(promise).rejects.toEqual(new Error(`There are no available calendars for profile '${profile}'`));
});
it('should throw if the specified calendar is not found', async () => {
const wrongCalendarName = 'Wrong calendar';
const promise = async (): Promise<unknown> => sync(profile, wrongCalendarName);
await expect(promise).rejects.toThrow(`There is no calendar '${wrongCalendarName}' for profile '${profile}'`);
});
it('should throw if there are no matching calendars', async () => {
const savedSchedule = fs.readJSON<Schedule>(getScheduleFile());
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const savedCalendar = savedSchedule.calendars[0] as LessonCalendar;
savedCalendar.name = 'random calendar name';
savedSchedule.calendars[0] = savedCalendar;
mockFs({
[getScheduleFile()]: JSON.stringify(savedSchedule),
});
const promise = async (): Promise<unknown> => sync(profile);
await expect(promise).rejects.toEqual(new Error(`Unknown calendar 'random calendar name' for profile '${profile}'`));
});
it('should throw if no time described for a lesson', async () => {
const savedSchedule = fs.readJSON<Schedule>(getScheduleFile());
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const savedCalendar = savedSchedule.calendars[0] as LessonCalendar;
savedCalendar.days[1]!.push('6th lesson');
savedSchedule.calendars[0] = savedCalendar;
mockFs({
[getScheduleFile()]: JSON.stringify(savedSchedule),
});
const promise = async (): Promise<unknown> => sync(profile);
await expect(promise).rejects.toEqual(new Error('Cannot find time described for lesson #6 in calendar \'First grade lessons\''));
});
it('should get events for calendars without showing progress', async () => {
await sync(profile);
expect(getItems).toHaveBeenCalledTimes(4);
expect(getItems).toHaveBeenCalledWith(
expect.toBeFunction([ calendarApis ], calendarApis.calendarList),
{ },
{ hideProgress: true },
);
calendars.forEach((calendar) => {
expect(getItems).toHaveBeenCalledWith(
expect.toBeFunction([ calendarApis ], calendarApis.events),
{ calendarId: calendar.id },
{ hideProgress: true },
);
});
});
it('should get events for the specified calendar without showing progress', async () => {
await sync(profile, selectedCalendar.summary);
expect(getItems).toHaveBeenCalledTimes(2);
expect(getItems).toHaveBeenCalledWith(
expect.toBeFunction([ calendarApis ], calendarApis.calendarList),
{ },
{ hideProgress: true },
);
expect(getItems).toHaveBeenCalledWith(
expect.toBeFunction([ calendarApis ], calendarApis.events),
{ calendarId: selectedCalendar.id },
{ hideProgress: true },
);
});
it('should delete all existing events in calendars', async () => {
await sync(profile);
expect(api.events.delete).toHaveBeenCalledTimes(9);
calendars.forEach((calendar) => {
expect(api.events.delete).toHaveBeenCalledWith({ calendarId: calendar.id, eventId: 'id1' });
expect(api.events.delete).toHaveBeenCalledWith({ calendarId: calendar.id, eventId: '' });
expect(api.events.delete).toHaveBeenCalledWith({ calendarId: calendar.id, eventId: 'id3' });
});
});
it('should create all events for schedule', async () => {
await sync(profile);
expect(api.events.insert.mock.calls.map(stringifyInsertCall).join('\n')).toMatchSnapshot();
});
});
});
// eslint-disable-next-line camelcase
function stringifyInsertCall(insertCall: [calendar_v3.Params$Resource$Events$Insert, MethodOptions]): string {
return [
insertCall[0].calendarId,
JSON.stringify(insertCall[1]),
insertCall[0].requestBody?.start?.dateTime,
insertCall[0].requestBody?.start?.timeZone,
insertCall[0].requestBody?.end?.dateTime,
insertCall[0].requestBody?.end?.timeZone,
insertCall[0].requestBody?.recurrence,
insertCall[0].requestBody?.summary,
insertCall[0].requestBody?.location,
].join('\t');
}