UNPKG

youtube-likes-downloader

Version:
284 lines (221 loc) 8.81 kB
import fs from 'fs'; import { getAPI } from '@anmiles/google-api-wrapper'; import '@anmiles/jest-extensions'; import logger from '@anmiles/logger'; import type GoogleApis from 'googleapis'; import { youtube } from 'googleapis/build/src/apis/youtube'; import mockFs from 'mock-fs'; import { exportLikes, importLikes } from '../likes'; import { getIncludeLikesFile, getLikesFile } from '../utils/paths'; jest.mock('@anmiles/google-api-wrapper'); jest.mock('@anmiles/logger'); jest.mock('googleapis/build/src/apis/youtube'); const profile = 'username'; const likesFile = getLikesFile(profile); const includeLikesFile = getIncludeLikesFile(profile); const youtubeApis = { playlistItems: 'playlistItems', } as const; function mockGetItems(selectAPI: (api: typeof youtubeApis) => typeof youtubeApis[keyof typeof youtubeApis]): typeof playlistItems { switch (selectAPI(youtubeApis)) { case youtubeApis.playlistItems: return playlistItems; } } const getItems = jest.mocked(jest.fn().mockImplementation(mockGetItems)); let playlistItems: Array<{ id?: string | null | undefined; snippet?: { title?: string; resourceId?: { videoId?: string } } }>; let likesData: string; let includeLikesData: string; const api = { videos: { rate: jest.fn(), }, }; const args = { playlistId: 'LL', part: [ 'snippet' ], maxResults: 50 }; const auth = { kind: 'auth' }; // eslint-disable-next-line @typescript-eslint/require-await -- allow partial mock const getAPIMock = jest.fn().mockImplementation(async () => ({ getItems, api })); jest.mocked(getAPI).mockImplementation((...args: unknown[]) => getAPIMock(...args)); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion jest.mocked(youtube).mockReturnValue(youtubeApis as unknown as GoogleApis.youtube_v3.Youtube); beforeEach(() => { playlistItems = [ { snippet: { title: 'video0', resourceId: { videoId: 'video0Id' } } }, { snippet: { title: 'video1', resourceId: { videoId: 'video1Id' } } }, { snippet: { title: 'video2', resourceId: { videoId: 'video2Id' } } }, { snippet: { title: 'video3', resourceId: { videoId: 'video3Id' } } }, ]; likesData = [ '# video0', 'https://www.youtube.com/watch?v=video0Id', '', '# video1', 'https://www.youtube.com/watch?v=video1Id', '', '# video2', 'https://www.youtube.com/watch?v=video2Id', '', '# video3', 'https://www.youtube.com/watch?v=video3Id', ].join('\n'); includeLikesData = [ '# video4', 'https://www.youtube.com/watch?v=video4Id', '', '# video5', 'https://www.youtube.com/watch?v=video5Id', '', ].join('\n'); mockFs({ [likesFile] : likesData, [includeLikesFile]: includeLikesData, }); }); afterAll(() => { mockFs.restore(); }); describe('src/lib/likes', () => { describe('importLikes', () => { it('should get youtube API', async () => { await importLikes(profile); expect(getAPI).toHaveBeenCalledWith(expect.toBeFunction([ auth ], youtubeApis), profile); expect(youtube).toHaveBeenCalledWith({ version: 'v3', auth }); }); it('should get data from playlistItems API', async () => { await importLikes(profile); expect(getItems).toHaveBeenCalledWith(expect.toBeFunction([ youtubeApis ], youtubeApis.playlistItems), args); }); it('should write likes into file', async () => { fs.rmSync(includeLikesFile); await importLikes(profile); expect(fs.readFileSync(likesFile).toString()).toEqual(likesData); }); it('should add likes from additional likes file if exists', async () => { await importLikes(profile); expect(fs.readFileSync(likesFile).toString()).toEqual(`${likesData}\n\n${includeLikesData}`); }); }); describe('exportLikes', () => { it('should get temporary youtube API', async () => { await exportLikes(profile); expect(getAPI).toHaveBeenCalledWith(expect.toBeFunction([ auth ], youtubeApis), profile, { temporary: true, scopes: [ 'https://www.googleapis.com/auth/youtube' ] }); expect(youtube).toHaveBeenCalledWith({ version: 'v3', auth }); }); it('should get data from playlistItems API', async () => { await exportLikes(profile); expect(getItems).toHaveBeenCalledWith(expect.toBeFunction([ youtubeApis ], youtubeApis.playlistItems), args); }); it('should throw if likes file does not exist', async () => { mockFs({}); await expect(exportLikes(profile)).rejects.toEqual(new Error(`Likes file ${likesFile} doesn't exist, please create it and paste each videos URL (like https://www.youtube.com/watch?v=abcabcabc) on each line`)); }); it('should process and log each video id in reverse order', async () => { const logSpy = jest.spyOn(logger, 'log'); await exportLikes(profile); expect(logSpy.mock.calls).toEqual([ [ 'video3Id' ], [ 'video2Id' ], [ 'video1Id' ], [ 'video0Id' ], ]); }); it('should not rate any video if all videos are already rated', async () => { await exportLikes(profile); expect(api.videos.rate).not.toHaveBeenCalled(); }); it('should only rate videos that still not rated', async () => { playlistItems = [ playlistItems[0]!, playlistItems[2]!, ]; await exportLikes(profile); expect(api.videos.rate).toHaveBeenCalledWith({ id: 'video1Id', rating: 'like' }); expect(api.videos.rate).toHaveBeenCalledWith({ id: 'video3Id', rating: 'like' }); }); describe('errors', () => { const minorErrors = [ { reason : 'videoNotFound', message: 'Native videoNotFound error message', }, { reason : 'notFound', message: 'Native notFound error message', }, { reason : 'videoRatingDisabled', message: 'Native videoRatingDisabled error message', }, { reason : 'videoPurchaseRequired', message: 'Native videoPurchaseRequired error message', }, ]; const fatalErrors = [ { reason : 'emailNotVerified', message: 'Native emailNotVerified error message', }, { reason : 'invalidRating', message: 'Native invalidRating error message', }, { reason : 'forbidden', message: 'Native forbidden error message', }, ]; const secondError = { reason : 'anotherError', message: 'Another error', }; beforeEach(() => { playlistItems = []; }); it('should warn about errors that can be skipped', async () => { const warnSpy = jest.spyOn(logger, 'warn'); minorErrors.forEach((error) => api.videos.rate.mockRejectedValueOnce({ errors: [ error, secondError ] })); await exportLikes(profile); expect(warnSpy.mock.calls).toEqual([ [ 'Video video3Id not found' ], [ 'Video video2Id not found' ], [ 'Video video1Id is not allowed to be rated' ], [ 'Video video0Id is not allowed to be rated' ], ]); }); fatalErrors.forEach((error) => { it(`should throw once fatal error '${error.reason}' occurred and do not call rate function anymore`, async () => { api.videos.rate.mockResolvedValueOnce(undefined); api.videos.rate.mockRejectedValueOnce({ errors: [ error, secondError ] }); await expect(exportLikes(profile)).rejects.toEqual({ errors: [ error, secondError ] }); expect(api.videos.rate).toHaveBeenCalledTimes(2); expect(api.videos.rate).toHaveBeenCalledWith({ id: 'video3Id', rating: 'like' }); expect(api.videos.rate).toHaveBeenCalledWith({ id: 'video2Id', rating: 'like' }); }); }); it('should re-throw unknown API errors', async () => { api.videos.rate.mockRejectedValueOnce({ errors: [ { reason: 'other reason' } ] }); api.videos.rate.mockRejectedValueOnce({ errors: [ { customKey: 'value' } ] }); await expect(exportLikes(profile)).rejects.toEqual({ errors: [ { reason: 'other reason' } ] }); await expect(exportLikes(profile)).rejects.toEqual({ errors: [ { customKey: 'value' } ] }); expect(api.videos.rate).toHaveBeenCalledTimes(2); }); it('should re-throw non-API errors', async () => { api.videos.rate.mockRejectedValueOnce('string error'); api.videos.rate.mockRejectedValueOnce(new Error('error')); api.videos.rate.mockRejectedValueOnce(null); api.videos.rate.mockRejectedValueOnce({}); await expect(exportLikes(profile)).rejects.toEqual('string error'); await expect(exportLikes(profile)).rejects.toEqual(new Error('error')); await expect(exportLikes(profile)).rejects.toEqual(null); await expect(exportLikes(profile)).rejects.toEqual({}); expect(api.videos.rate).toHaveBeenCalledTimes(4); }); it('should not throw if API error doesn\'t have errors in `errors` list', async () => { api.videos.rate.mockRejectedValueOnce({ errors: null }); api.videos.rate.mockRejectedValueOnce({ errors: [] }); await exportLikes(profile); expect(api.videos.rate).toHaveBeenCalledTimes(4); }); }); }); });