UNPKG

school-schedule-sync

Version:

Synchronization between JSON schedule and Google Calendar

248 lines (190 loc) 7.76 kB
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'); }