import { describe, it, expect, vi, beforeEach } from 'vitest'; import { syncWithWaniKani } from '../../src/services/sync.service.js'; import { StudyItem } from '../../src/models/StudyItem.js'; vi.mock('../../src/models/StudyItem.js'); global.fetch = vi.fn(); describe('Sync Service', () => { beforeEach(() => { vi.clearAllMocks(); }); const mockUser = { _id: 'u1', wkApiKey: 'key', settings: {} }; it('should throw if no API key', async () => { await expect(syncWithWaniKani({})) .rejects.toThrow('User has no WaniKani API Key'); }); it('should handle API fetch error', async () => { fetch.mockResolvedValue({ ok: false, statusText: 'Unauthorized' }); await expect(syncWithWaniKani(mockUser)) .rejects.toThrow('Failed to connect to WaniKani'); }); it('should return 0 if no unlocked kanji', async () => { fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [], pages: { next_url: null } }) }); const res = await syncWithWaniKani(mockUser); expect(res.count).toBe(0); expect(res.message).toContain('No unlocked kanji'); }); it('should sync new items with full reading data', async () => { fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ data: { subject_id: 101 } }], pages: { next_url: null } }) }); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: 101, data: { characters: 'A', meanings: [{ primary: true, meaning: 'A_Meaning' }], level: 5, readings: [ { type: 'onyomi', reading: 'on' }, { type: 'kunyomi', reading: 'kun' }, { type: 'nanori', reading: 'nan' } ] } }] }) }); StudyItem.insertMany.mockResolvedValue(true); StudyItem.countDocuments.mockResolvedValue(1); await syncWithWaniKani(mockUser); expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ wkSubjectId: 101, onyomi: ['on'], kunyomi: ['kun'], nanori: ['nan'] }) ])); }); it('should filter out existing items', async () => { fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [ { data: { subject_id: 101 } }, { data: { subject_id: 102 } } ], pages: { next_url: null } }) }); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([{ wkSubjectId: 102 }]) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: 101, data: { characters: 'New', meanings: [], level: 1, readings: [] } }] }) }); StudyItem.insertMany.mockResolvedValue(true); StudyItem.countDocuments.mockResolvedValue(2); await syncWithWaniKani(mockUser); expect(StudyItem.insertMany).toHaveBeenCalledTimes(1); expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ wkSubjectId: 101 }) ])); expect(StudyItem.insertMany).not.toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ wkSubjectId: 102 }) ])); }); it('should handle assignment pagination', async () => { fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ data: { subject_id: 1 } }], pages: { next_url: 'http://next-page' } }) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ data: { subject_id: 2 } }], pages: { next_url: null } }) }); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [] }) }); StudyItem.countDocuments.mockResolvedValue(2); await syncWithWaniKani(mockUser); expect(fetch).toHaveBeenCalledTimes(3); }); it('should skip insert if operations are empty (e.g. subject data missing)', async () => { fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ data: { subject_id: 101 } }], pages: { next_url: null } }) }); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [] }) }); StudyItem.countDocuments.mockResolvedValue(0); await syncWithWaniKani(mockUser); expect(StudyItem.insertMany).not.toHaveBeenCalled(); }); it('should sync items in chunks', async () => { const manyIds = Array.from({ length: 100 }, (_, i) => i + 1); const subjectData = manyIds.map(id => ({ data: { subject_id: id } })); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: subjectData, pages: { next_url: null } }) }); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); fetch .mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: 1, data: { characters: 'C', meanings: [], level: 1 } }] }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: 101, data: { characters: 'D', meanings: [], level: 1 } }] }) }); StudyItem.insertMany.mockResolvedValue(true); StudyItem.countDocuments.mockResolvedValue(100); await syncWithWaniKani(mockUser); expect(fetch).toHaveBeenCalledTimes(3); expect(StudyItem.insertMany).toHaveBeenCalledTimes(2); }); it('should fetch and map radicals for items, handling missing meanings', async () => { fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ data: { subject_id: 500 } }], pages: { next_url: null } }) }); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: 500, data: { characters: 'Kanji', meanings: [{ primary: true, meaning: 'K_Mean' }], level: 5, readings: [], component_subject_ids: [10, 11, 12] } }] }) }); fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [ { id: 10, data: { characters: 'R1', meanings: [{ primary: true, meaning: 'Rad1' }], character_images: [] } }, { id: 11, data: { characters: null, meanings: [{ primary: true, meaning: 'Rad2' }], character_images: [ { content_type: 'image/png', url: 'bad.png', metadata: {} }, { content_type: 'image/svg+xml', url: 'good.svg', metadata: { inline_styles: false } } ] } }, { id: 12, data: { characters: 'R2', meanings: [{ primary: false, meaning: 'Secondary' }], character_images: [] } } ] }) }); StudyItem.insertMany.mockResolvedValue(true); StudyItem.countDocuments.mockResolvedValue(1); await syncWithWaniKani(mockUser); expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ wkSubjectId: 500, radicals: expect.arrayContaining([ expect.objectContaining({ meaning: 'Rad1', char: 'R1' }), expect.objectContaining({ meaning: 'Rad2', image: 'good.svg' }), expect.objectContaining({ meaning: 'Unknown', char: 'R2' }) ]) }) ])); }); });