import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test' const successMock = mock(() => {}) const errorMock = mock(() => {}) globalThis.useNotificationStore = () => ({ add: () => 'test-id', markRead: () => {}, }) as any mock.module('~/composables/useNotification', () => ({ useNotification: () => ({ success: successMock, error: errorMock }), default: () => ({ success: successMock, error: errorMock }), })) mock.module('~/stores/notification', () => ({ useNotificationStore: () => ({ add: () => 'test-id', markRead: () => {}, }), })) // eslint-disable-next-line import/first import * as utils from '~/utils/index' // eslint-disable-next-line import/first import { useTasks } from '~/composables/useTasks' // eslint-disable-next-line import/first import type { Task, TaskInspectResponse, TaskMetadataResponse, Pagination } from '~/types/tasks' const mockTask: Task = { id: 1, name: 'Test Task', url: 'https://www.youtube.com/channel/UCtest123', folder: 'youtube/Test', preset: 'default', timer: '0 12 * * *', template: '%(title)s.%(ext)s', cli: '--format best', auto_start: true, handler_enabled: true, enabled: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', } const mockPagination: Pagination = { page: 1, per_page: 50, total: 1, total_pages: 1, has_next: false, has_prev: false, } function createMockResponse({ ok, status, jsonData }: { ok: boolean; status: number; jsonData: any }) { return { ok, status, headers: new Headers(), redirected: false, statusText: '', type: 'basic', url: '', body: null, bodyUsed: false, clone() { return this }, async json() { return jsonData }, text: async () => JSON.stringify(jsonData), arrayBuffer: async () => new ArrayBuffer(0), blob: async () => new Blob(), formData: async () => new FormData(), } as Response } describe('useTasks', () => { beforeEach(() => { successMock.mockClear() errorMock.mockClear() const tasks = useTasks() tasks.__resetForTesting() }) describe('loadTasks', () => { it('loads tasks with pagination successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [mockTask], pagination: mockPagination, }, }), ) const tasks = useTasks() await tasks.loadTasks() expect(tasks.tasks.value).toHaveLength(1) expect(tasks.tasks.value[0]).toEqual(mockTask) expect(tasks.pagination.value).toEqual(mockPagination) expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('sorts tasks by name A-Z', async () => { const items = [ { ...mockTask, id: 1, name: 'Zebra' }, { ...mockTask, id: 2, name: 'Alpha' }, { ...mockTask, id: 3, name: 'Beta' }, ] const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items, pagination: mockPagination, }, }), ) const tasks = useTasks() await tasks.loadTasks() const sorted = tasks.tasks.value expect(sorted[0].name).toBe('Alpha') expect(sorted[1].name).toBe('Beta') expect(sorted[2].name).toBe('Zebra') requestSpy.mockRestore() }) it('handles empty tasks list', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [], pagination: { ...mockPagination, total: 0 }, }, }), ) const tasks = useTasks() await tasks.loadTasks() expect(tasks.tasks.value).toEqual([]) expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('handles errors during load', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 500, jsonData: { error: 'Server error' }, }), ) const tasks = useTasks() await tasks.loadTasks() expect(tasks.lastError.value).toBeTruthy() requestSpy.mockRestore() }) it('respects page and per_page parameters', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [mockTask], pagination: { ...mockPagination, page: 2 }, }, }), ) const tasks = useTasks() await tasks.loadTasks(2, 25) expect(requestSpy).toHaveBeenCalledWith('/api/tasks/?page=2&per_page=25') requestSpy.mockRestore() }) }) describe('getTask', () => { it('fetches a single task successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) const tasks = useTasks() const result = await tasks.getTask(1) expect(result).toEqual(mockTask) expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('handles 404 not found', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 404, jsonData: { error: 'Task not found' }, }), ) const tasks = useTasks() const result = await tasks.getTask(999) expect(result).toBeNull() expect(tasks.lastError.value).toBeTruthy() requestSpy.mockRestore() }) }) describe('createTask', () => { it('creates a task successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) const tasks = useTasks() const initialTotal = tasks.pagination.value.total const newTask = { ...mockTask } delete (newTask as any).id const result = await tasks.createTask(newTask) expect(result).toEqual(mockTask) expect(tasks.tasks.value).toContainEqual(mockTask) expect(tasks.pagination.value.total).toBe(initialTotal + 1) requestSpy.mockRestore() }) it('calls callback on success', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) const callback = mock(() => {}) const tasks = useTasks() const newTask = { ...mockTask } delete (newTask as any).id await tasks.createTask(newTask, callback) expect(callback).toHaveBeenCalledWith({ success: true, error: null, detail: null, data: mockTask, }) requestSpy.mockRestore() }) it('calls callback on error', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 400, jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] }, }), ) const callback = mock(() => {}) const tasks = useTasks() const newTask = { ...mockTask } delete (newTask as any).id await tasks.createTask(newTask, callback) expect(callback).toHaveBeenCalledWith( expect.objectContaining({ success: false, error: expect.any(String), }), ) requestSpy.mockRestore() }) }) describe('updateTask', () => { it('updates a task successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { ...mockTask, name: 'Updated Name' }, }), ) const tasks = useTasks() const updated = { ...mockTask, name: 'Updated Name' } const result = await tasks.updateTask(1, updated) expect(result?.name).toBe('Updated Name') requestSpy.mockRestore() }) it('calls callback on success', async () => { const updatedTask = { ...mockTask, name: 'Updated' } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: updatedTask, }), ) const callback = mock(() => {}) const tasks = useTasks() await tasks.updateTask(1, updatedTask, callback) expect(callback).toHaveBeenCalledWith({ success: true, error: null, detail: null, data: updatedTask, }) requestSpy.mockRestore() }) it('removes id field from task before sending', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) const tasks = useTasks() const taskWithId = { ...mockTask } await tasks.updateTask(1, taskWithId) const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body) expect('id' in requestBody).toBe(false) requestSpy.mockRestore() }) }) describe('patchTask', () => { it('patches a task successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { ...mockTask, enabled: false }, }), ) const tasks = useTasks() const result = await tasks.patchTask(1, { enabled: false }) expect(result?.enabled).toBe(false) requestSpy.mockRestore() }) it('calls callback on patch success', async () => { const patchedTask = { ...mockTask, enabled: false } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: patchedTask, }), ) const callback = mock(() => {}) const tasks = useTasks() await tasks.patchTask(1, { enabled: false }, callback) expect(callback).toHaveBeenCalledWith({ success: true, error: null, detail: null, data: patchedTask, }) requestSpy.mockRestore() }) it('handles validation errors with callback', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 400, jsonData: { detail: [{ loc: ['timer'], msg: 'Invalid CRON expression', type: 'value_error' }] }, }), ) const callback = mock(() => {}) const tasks = useTasks() await tasks.patchTask(1, { timer: 'invalid' }, callback) expect(callback).toHaveBeenCalledWith( expect.objectContaining({ success: false, error: expect.any(String), }), ) requestSpy.mockRestore() }) }) describe('deleteTask', () => { it('deletes a task successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) const tasks = useTasks() const initialTotal = tasks.pagination.value.total const result = await tasks.deleteTask(mockTask.id!) expect(result).toBe(true) expect(tasks.pagination.value.total).toBe(Math.max(0, initialTotal - 1)) requestSpy.mockRestore() }) it('calls callback on delete success', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) const callback = mock(() => {}) const tasks = useTasks() await tasks.deleteTask(1, callback) expect(callback).toHaveBeenCalledWith({ success: true, error: null, detail: null, data: true, }) requestSpy.mockRestore() }) it('calls callback on delete error', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 404, jsonData: { error: 'Task not found' }, }), ) const callback = mock(() => {}) const tasks = useTasks() await tasks.deleteTask(999, callback) expect(callback).toHaveBeenCalledWith( expect.objectContaining({ success: false, error: expect.any(String), data: false, }), ) requestSpy.mockRestore() }) }) describe('inspectTaskHandler', () => { it('inspects task handler successfully', async () => { const inspectResponse: TaskInspectResponse = { matched: true, handler: 'YoutubeHandler', supported: true, items: [ { url: 'https://www.youtube.com/watch?v=test123', title: 'Test Video', archive_id: 'youtube test123', metadata: { published: '2024-01-01T00:00:00Z' }, }, ], metadata: { feed_url: 'https://www.youtube.com/feeds/videos.xml?channel_id=UCtest123', entry_count: 1, }, } const fetchSpy = spyOn(globalThis, 'fetch') fetchSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: inspectResponse, }), ) const tasks = useTasks() const result = await tasks.inspectTaskHandler({ url: 'https://www.youtube.com/channel/UCtest123', }) expect(result).toEqual(inspectResponse) expect(result?.matched).toBe(true) expect(result?.handler).toBe('YoutubeHandler') fetchSpy.mockRestore() }) it('handles handler not supported', async () => { const inspectResponse: TaskInspectResponse = { matched: false, handler: null, supported: false, items: [], metadata: null, } const fetchSpy = spyOn(globalThis, 'fetch') fetchSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: inspectResponse, }), ) const tasks = useTasks() const result = await tasks.inspectTaskHandler({ url: 'https://unsupported.com', }) expect(result?.supported).toBe(false) expect(result?.matched).toBe(false) fetchSpy.mockRestore() }) it('handles inspect errors', async () => { const fetchSpy = spyOn(globalThis, 'fetch') fetchSpy.mockRejectedValueOnce(new Error('Network error')) const tasks = useTasks() const result = await tasks.inspectTaskHandler({ url: 'invalid', }) expect(result).toBeNull() expect(tasks.lastError.value).toBeTruthy() fetchSpy.mockRestore() }) }) describe('markTaskItems', () => { it('marks all items as downloaded successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { message: 'Marked 15 items' }, }), ) const tasks = useTasks() const result = await tasks.markTaskItems(1) expect(result).toBe('Marked 15 items') expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('handles mark errors', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 404, jsonData: { error: 'Task not found' }, }), ) const tasks = useTasks() const result = await tasks.markTaskItems(999) expect(result).toBeNull() expect(tasks.lastError.value).toBeTruthy() requestSpy.mockRestore() }) }) describe('unmarkTaskItems', () => { it('unmarks items successfully', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { message: 'Unmarked 10 items' }, }), ) const tasks = useTasks() const result = await tasks.unmarkTaskItems(1) expect(result).toBe('Unmarked 10 items') expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('handles unmark errors', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 404, jsonData: { error: 'Task not found' }, }), ) const tasks = useTasks() const result = await tasks.unmarkTaskItems(999) expect(result).toBeNull() expect(tasks.lastError.value).toBeTruthy() requestSpy.mockRestore() }) }) describe('generateTaskMetadata', () => { it('generates metadata successfully', async () => { const metadataResponse: TaskMetadataResponse = { status: 'success', generated: ['tvshow.nfo', 'info.json', 'poster.jpg'], } const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: metadataResponse, }), ) const tasks = useTasks() const result = await tasks.generateTaskMetadata(1) expect(result).toEqual(metadataResponse) expect(result?.status).toBe('success') expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('handles metadata generation errors', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 500, jsonData: { error: 'Failed to generate metadata' }, }), ) const tasks = useTasks() const result = await tasks.generateTaskMetadata(1) expect(result).toBeNull() expect(tasks.lastError.value).toBeTruthy() requestSpy.mockRestore() }) }) describe('error handling', () => { it('throws when throwInstead is true', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 500, jsonData: { error: 'Server error' }, }), ) const tasks = useTasks() tasks.throwInstead.value = true await expect(tasks.loadTasks()).rejects.toThrow() requestSpy.mockRestore() }) it('clears error on clearError call', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 500, jsonData: { error: 'Server error' }, }), ) const tasks = useTasks() tasks.throwInstead.value = false await tasks.loadTasks() expect(tasks.lastError.value).toBeTruthy() tasks.clearError() expect(tasks.lastError.value).toBeNull() requestSpy.mockRestore() }) it('parses API validation errors correctly', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 400, jsonData: { detail: [ { loc: ['name'], msg: 'Field required', type: 'value_error.missing' }, { loc: ['url'], msg: 'Invalid URL format', type: 'value_error.url' }, ], }, }), ) const tasks = useTasks() tasks.clearError() const newTask = { ...mockTask, name: '', url: '' } delete (newTask as any).id const result = await tasks.createTask(newTask) expect(result).toBeNull() expect(tasks.lastError.value).toBeTruthy() expect(tasks.lastError.value).toContain('name: Field required') expect(tasks.lastError.value).toContain('url: Invalid URL format') requestSpy.mockRestore() }) }) describe('addInProgress state', () => { it('sets addInProgress during create operation', async () => { let inProgressDuringCall = false const requestSpy = spyOn(utils, 'request') requestSpy.mockImplementation(async (_url, _options) => { const currentTasks = useTasks() inProgressDuringCall = currentTasks.addInProgress.value return createMockResponse({ ok: true, status: 200, jsonData: mockTask, }) }) const tasks = useTasks() const newTask = { ...mockTask } delete (newTask as any).id await tasks.createTask(newTask) expect(inProgressDuringCall).toBe(true) expect(tasks.addInProgress.value).toBe(false) requestSpy.mockRestore() }) it('resets addInProgress on error', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: false, status: 400, jsonData: { error: 'Bad request' }, }), ) const tasks = useTasks() const newTask = { ...mockTask } delete (newTask as any).id await tasks.createTask(newTask) expect(tasks.addInProgress.value).toBe(false) requestSpy.mockRestore() }) }) describe('isLoading state', () => { it('sets isLoading during loadTasks operation', async () => { let loadingDuringCall = false const requestSpy = spyOn(utils, 'request') requestSpy.mockImplementation(async (_url, _options) => { const currentTasks = useTasks() loadingDuringCall = currentTasks.isLoading.value return createMockResponse({ ok: true, status: 200, jsonData: { items: [mockTask], pagination: mockPagination, }, }) }) const tasks = useTasks() await tasks.loadTasks() expect(loadingDuringCall).toBe(true) expect(tasks.isLoading.value).toBe(false) requestSpy.mockRestore() }) }) describe('task list updates', () => { it('updates existing task in list', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [mockTask], pagination: mockPagination, }, }), ) const tasks = useTasks() await tasks.loadTasks() expect(tasks.tasks.value).toHaveLength(1) expect(tasks.tasks.value[0].name).toBe('Test Task') const updatedTask = { ...mockTask, name: 'Updated Name' } requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: updatedTask, }), ) await tasks.updateTask(1, updatedTask) expect(tasks.tasks.value[0].name).toBe('Updated Name') requestSpy.mockRestore() }) it('removes deleted task from list', async () => { const requestSpy = spyOn(utils, 'request') requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: { items: [mockTask], pagination: { ...mockPagination, total: 1 }, }, }), ) const tasks = useTasks() await tasks.loadTasks() expect(tasks.tasks.value).toHaveLength(1) expect(tasks.pagination.value.total).toBe(1) requestSpy.mockResolvedValueOnce( createMockResponse({ ok: true, status: 200, jsonData: mockTask, }), ) await tasks.deleteTask(1) expect(tasks.tasks.value).toHaveLength(0) expect(tasks.pagination.value.total).toBe(0) requestSpy.mockRestore() }) }) })