import type { ReactNode } from 'react' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, expect, test, vi } from 'vitest' import App from './App' const dragAndDrop = vi.hoisted(() => ({ onTaskDrop: null as ((taskId: string, status: string) => void) | null, })) vi.mock('./TaskDragAndDrop', () => ({ TaskDragDropProvider: ({ children, onTaskDrop, }: { children: ReactNode onTaskDrop: (taskId: string, status: string) => void }) => { dragAndDrop.onTaskDrop = onTaskDrop return children }, useTaskDraggable: () => ({ ref: () => {}, isDragging: false, }), useTaskColumnDropTarget: () => ({ ref: () => {}, isDropTarget: false, }), })) const users = [ { id: 'd56b54dd-31b0-4d71-8a10-82464be59a61', name: 'Urban', createdAt: '2026-07-24T10:15:30Z', }, { id: '2213a673-c859-462b-a031-7b68b6f01c73', name: 'Anna', createdAt: '2026-07-24T10:16:30Z', }, ] const tasks = [ { id: '00000000-0000-0000-0000-000000000001', title: 'Dammsuga', description: 'Bottenvåningen', status: 'WAITING', points: 7, assignee: null, createdAt: '2026-07-24T10:00:00Z', }, { id: '00000000-0000-0000-0000-000000000002', title: 'Diska', description: null, status: 'IN_PROGRESS', points: 3, assignee: { id: users[1].id, name: users[1].name }, createdAt: '2026-07-24T10:01:00Z', }, { id: '00000000-0000-0000-0000-000000000003', title: 'Vattna blommor', description: null, status: 'COMPLETED', points: 5, assignee: null, createdAt: '2026-07-24T10:02:00Z', }, ] beforeEach(() => { window.localStorage.clear() dragAndDrop.onTaskDrop = null }) afterEach(() => { cleanup() vi.restoreAllMocks() }) test('tom användarlista visar Skapa användare', async () => { mockJsonResponse([]) render() expect(await screen.findByRole('heading', { name: 'Skapa användare' })).toBeInTheDocument() }) test('befintliga användare utan lokalt val visar Vem är du', async () => { mockJsonResponse(users) render() expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Anna' })).toBeInTheDocument() }) test('val av användare lagrar id och visar brädan', async () => { mockUsersAndTasks(users, []) render() fireEvent.click(await screen.findByRole('button', { name: 'Urban' })) expect(await screen.findByRole('heading', { name: 'Uppgifter' })).toBeInTheDocument() expect(screen.getByText('Urban')).toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(users[0].id) }) test('skapad användare blir automatiskt aktiv', async () => { const createdUser = users[0] const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse([])) fetchMock.mockResolvedValueOnce(jsonResponse(createdUser, 201)) fetchMock.mockResolvedValueOnce(jsonResponse([])) render() fireEvent.change(await screen.findByLabelText('Namn'), { target: { value: ' Urban ' } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa användare' })) expect(await screen.findByRole('heading', { name: 'Uppgifter' })).toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(createdUser.id) expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Urban' }), }) }) test('skapandefel för användare visas utan att namnet försvinner', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse([])) fetchMock.mockResolvedValueOnce( jsonResponse( { code: 'USER_NAME_ALREADY_EXISTS', message: 'En användare med det namnet finns redan.', }, 409, ), ) render() const input = await screen.findByLabelText('Namn') fireEvent.change(input, { target: { value: 'Urban' } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa användare' })) expect(await screen.findByRole('alert')).toHaveTextContent( 'En användare med det namnet finns redan.', ) expect(input).toHaveValue('Urban') }) test('Logga ut rensar valt id och väljer inte användaren automatiskt', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, []) render() fireEvent.click(await screen.findByRole('button', { name: 'Logga ut' })) expect(screen.getByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Uppgifter' })).not.toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull() }) test('hämtningsfel för användare visas som fel och kan återförsökas', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockRejectedValueOnce(new Error('Nätverksfel')) fetchMock.mockResolvedValueOnce(jsonResponse(users)) render() expect(await screen.findByText('Kunde inte ansluta till HemHub.')).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Skapa användare' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Försök igen' })) expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument() expect(fetchMock).toHaveBeenCalledTimes(2) }) test('ogiltigt lagrat användar-id rensas', async () => { window.localStorage.setItem('hemhub.activeUserId', 'finns-inte') mockJsonResponse(users) render() expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull() }) test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, tasks) render() await screen.findByText('Dammsuga') const waiting = await screen.findByRole('region', { name: 'Väntande' }) const inProgress = screen.getByRole('region', { name: 'Pågående' }) const completed = screen.getByRole('region', { name: 'Klart' }) expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() expect(within(waiting).getByText('7 p')).toBeInTheDocument() expect(within(inProgress).getByText('Diska')).toBeInTheDocument() expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument() expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument() }) test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, []) render() fireEvent.click(await screen.findByRole('button', { name: 'Ny uppgift' })) expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByLabelText('Titel')).toHaveFocus() expect(screen.getByLabelText('Poäng')).toHaveValue(1) expect(screen.getByLabelText('Tilldela')).toHaveValue('') expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Ingen' })) .toBeInTheDocument() expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Urban' })) .toBeInTheDocument() expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Anna' })) .toBeInTheDocument() }) test.each([ { sätt: 'stängningskrysset', close: () => fireEvent.click(screen.getByRole('button', { name: 'Stäng' })), }, { sätt: 'Escape', close: () => fireEvent.keyDown(window, { key: 'Escape' }), }, { sätt: 'modalens bakgrund', close: () => { const backdrop = screen.getByRole('dialog', { name: 'Skapa ny uppgift' }).parentElement if (!backdrop) { throw new Error('Modalens bakgrund saknas') } fireEvent.mouseDown(backdrop) }, }, ])('modalen stängs med $sätt och rensar formuläret', async ({ close }) => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, []) render() fireEvent.click(await screen.findByRole('button', { name: 'Ny uppgift' })) fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } }) fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { target: { value: 'Bottenvåningen' }, }) fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } }) close() expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) expect(screen.getByLabelText('Titel')).toHaveValue('') expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('') expect(screen.getByLabelText('Poäng')).toHaveValue(1) }) test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => { const createdTask = { id: '00000000-0000-0000-0000-000000000004', title: 'Putsa fönster', description: 'Köket', status: 'WAITING', points: 7, assignee: { id: users[1].id, name: users[1].name }, createdAt: '2026-07-24T10:03:00Z', } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201)) render() await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) fireEvent.change(screen.getByLabelText('Titel'), { target: { value: ' Putsa fönster ' } }) fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { target: { value: ' Köket ' }, }) fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } }) fireEvent.change(screen.getByLabelText('Tilldela'), { target: { value: users[1].id } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(), ) const waiting = await screen.findByRole('region', { name: 'Väntande' }) expect( within(waiting) .getAllByRole('article') .map((card) => within(card).getByRole('heading').textContent), ).toEqual(['Dammsuga', 'Putsa fönster']) expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket', points: 7, assigneeId: users[1].id, }), }) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) expect(screen.getByLabelText('Poäng')).toHaveValue(1) expect(screen.getByLabelText('Tilldela')).toHaveValue('') }) test('Ingen skickas som null när en uppgift skapas', async () => { const createdTask = { ...tasks[0], id: '00000000-0000-0000-0000-000000000010', title: 'Torka bordet', } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([])) fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201)) render() await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Torka bordet' } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Torka bordet', description: null, points: 1, assigneeId: null, }), }) }) test('statusanrop byter inte en befintlig ansvarig', async () => { const assignedWaiting = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name }, } const updatedTask = { ...assignedWaiting, status: 'IN_PROGRESS' } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([assignedWaiting])) fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask)) render() const card = (await screen.findByText('Dammsuga')).closest('article')! fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' })) const inProgress = screen.getByRole('region', { name: 'Pågående' }) expect(await within(inProgress).findByText('Anna')).toBeInTheDocument() expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument() }) test('alla statusar har redigerbar tilldelning med statusberoende alternativ', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, tasks) render() const waitingCard = (await screen.findByText('Dammsuga')).closest('article') const inProgressCard = screen.getByText('Diska').closest('article') const completedCard = screen.getByText('Vattna blommor').closest('article') expect(waitingCard).not.toBeNull() expect(inProgressCard).not.toBeNull() expect(completedCard).not.toBeNull() expect(within(waitingCard!).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) .toHaveTextContent('Ta uppgift') expect(within(inProgressCard!).getByText('Anna')).toBeInTheDocument() expect(within(completedCard!).getByText('Otilldelad')).toBeInTheDocument() fireEvent.click( within(inProgressCard!).getByRole('button', { name: 'Ändra ansvarig för Diska' }), ) expect( within(inProgressCard!).queryByRole('option', { name: 'Ingen' }), ).not.toBeInTheDocument() fireEvent.click( within(completedCard!).getByRole('button', { name: 'Ändra ansvarig för Vattna blommor', }), ) expect(within(completedCard!).getByRole('option', { name: 'Ingen' })).toBeInTheDocument() }) test('visar rätt statusknappar för varje kolumn', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) mockUsersAndTasks(users, tasks) render() const waitingCard = (await screen.findByText('Dammsuga')).closest('article')! const inProgressCard = screen.getByText('Diska').closest('article')! const completedCard = screen.getByText('Vattna blommor').closest('article')! expect(within(waitingCard).getByRole('button', { name: 'Påbörja' })).toBeInTheDocument() expect(within(waitingCard).getByRole('button', { name: 'Markera klar' })).toBeInTheDocument() expect(within(inProgressCard).getByRole('button', { name: 'Till Väntande' })) .toBeInTheDocument() expect(within(inProgressCard).getByRole('button', { name: 'Markera klar' })) .toBeInTheDocument() expect(within(completedCard).getByRole('button', { name: 'Till Väntande' })) .toBeInTheDocument() expect(within(completedCard).getByRole('button', { name: 'Påbörja igen' })) .toBeInTheDocument() }) test.each([ { task: tasks[0], button: 'Påbörja', target: 'IN_PROGRESS' }, { task: tasks[0], button: 'Markera klar', target: 'COMPLETED' }, { task: tasks[1], button: 'Till Väntande', target: 'WAITING' }, { task: tasks[1], button: 'Markera klar', target: 'COMPLETED' }, { task: tasks[2], button: 'Till Väntande', target: 'WAITING' }, { task: tasks[2], button: 'Påbörja igen', target: 'IN_PROGRESS' }, ])('$button skickar status $target', async ({ task, button, target }) => { const updatedTask = { ...task, status: target, assignee: target === 'IN_PROGRESS' && !task.assignee ? { id: users[0].id, name: users[0].name } : task.assignee, } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([task])) fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask)) render() const card = (await screen.findByText(task.title)).closest('article')! fireEvent.click(within(card).getByRole('button', { name: button })) await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${task.id}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: target, ...(target === 'IN_PROGRESS' ? { activeUserId: users[0].id } : {}), }), }) }) test('status uppdateras först efter serversvar och låser endast berört kort', async () => { const otherTask = { ...tasks[0], id: '00000000-0000-0000-0000-000000000010', title: 'Putsa fönster', } const updatedTask = { ...tasks[0], status: 'IN_PROGRESS', assignee: { id: users[0].id, name: users[0].name }, } let resolveStatus!: (response: Response) => void const statusResponse = new Promise((resolve) => { resolveStatus = resolve }) window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask])) fetchMock.mockReturnValueOnce(statusResponse) render() const waiting = await screen.findByRole('region', { name: 'Väntande' }) const card = (await within(waiting).findByText('Dammsuga')).closest('article')! const otherCard = within(waiting).getByText('Putsa fönster').closest('article')! const startButton = within(card).getByRole('button', { name: 'Påbörja' }) fireEvent.click(startButton) fireEvent.click(startButton) expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(startButton).toBeDisabled() expect(within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) .toBeDisabled() expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled() expect(fetchMock).toHaveBeenCalledTimes(3) resolveStatus(jsonResponse(updatedTask)) const inProgress = screen.getByRole('region', { name: 'Pågående' }) expect(await within(inProgress).findByText('Dammsuga')).toBeInTheDocument() expect(within(inProgress).getByText('Urban')).toBeInTheDocument() }) test('statusfel behåller tidigare status och ansvarig och visas på kortet', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) fetchMock.mockResolvedValueOnce( jsonResponse( { code: 'TASK_REQUIRES_ASSIGNEE', message: 'En pågående uppgift måste ha en ansvarig.', }, 409, ), ) render() const waiting = await screen.findByRole('region', { name: 'Väntande' }) const card = (await within(waiting).findByText('Dammsuga')).closest('article')! fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' })) expect(await within(card).findByRole('alert')).toHaveTextContent( 'En pågående uppgift måste ha en ansvarig.', ) expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(card).getByText('Ta uppgift')).toBeInTheDocument() }) test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => { const otherTask = { ...tasks[0], id: '00000000-0000-0000-0000-000000000010', title: 'Putsa fönster', } const serverTask = { ...tasks[0], status: 'COMPLETED', assignee: { id: users[1].id, name: users[1].name }, } let resolveStatus!: (response: Response) => void const statusResponse = new Promise((resolve) => { resolveStatus = resolve }) window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask])) fetchMock.mockReturnValueOnce(statusResponse) render() await screen.findByText('Dammsuga') act(() => dropTask(tasks[0].id, 'IN_PROGRESS')) const inProgress = screen.getByRole('region', { name: 'Pågående' }) const optimisticCard = (await within(inProgress).findByText('Dammsuga')).closest('article')! const waiting = screen.getByRole('region', { name: 'Väntande' }) const otherCard = within(waiting).getByText('Putsa fönster').closest('article')! expect(within(optimisticCard).getByText('Urban')).toBeInTheDocument() expect(optimisticCard).toHaveAttribute('aria-busy', 'true') expect(optimisticCard).toHaveClass('task-card-pending') expect(within(optimisticCard).getByRole('button', { name: 'Markera klar' })).toBeDisabled() expect( within(optimisticCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }), ).toBeDisabled() expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled() expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'IN_PROGRESS', activeUserId: users[0].id, }), }) act(() => dropTask(tasks[0].id, 'COMPLETED')) expect(fetchMock).toHaveBeenCalledTimes(3) await act(async () => resolveStatus(jsonResponse(serverTask))) const completed = screen.getByRole('region', { name: 'Klart' }) const confirmedCard = (await within(completed).findByText('Dammsuga')).closest('article')! expect(within(confirmedCard).getByText('Anna')).toBeInTheDocument() expect(confirmedCard).not.toHaveAttribute('aria-busy') }) test('dragfel återställer hela uppgiften och visar lokalt fel', async () => { let resolveStatus!: (response: Response) => void const statusResponse = new Promise((resolve) => { resolveStatus = resolve }) window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) fetchMock.mockReturnValueOnce(statusResponse) render() await screen.findByText('Dammsuga') act(() => dropTask(tasks[0].id, 'IN_PROGRESS')) const inProgress = screen.getByRole('region', { name: 'Pågående' }) expect(await within(inProgress).findByText('Urban')).toBeInTheDocument() await act(async () => resolveStatus( jsonResponse( { code: 'TASK_REQUIRES_ASSIGNEE', message: 'En pågående uppgift måste ha en ansvarig.', }, 409, ), ), ) const waiting = screen.getByRole('region', { name: 'Väntande' }) const restoredCard = (await within(waiting).findByText('Dammsuga')).closest('article')! expect(within(restoredCard).getByText('Ta uppgift')).toBeInTheDocument() expect(within(restoredCard).queryByText('Urban')).not.toBeInTheDocument() expect(await within(restoredCard).findByRole('alert')).toHaveTextContent( 'En pågående uppgift måste ha en ansvarig.', ) expect(restoredCard).not.toHaveAttribute('aria-busy') }) test('drag till Pågående behåller en befintlig ansvarig optimistiskt', async () => { const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name }, } let resolveStatus!: (response: Response) => void const statusResponse = new Promise((resolve) => { resolveStatus = resolve }) window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask])) fetchMock.mockReturnValueOnce(statusResponse) render() await screen.findByText('Dammsuga') act(() => dropTask(assignedTask.id, 'IN_PROGRESS')) const inProgress = screen.getByRole('region', { name: 'Pågående' }) expect(await within(inProgress).findByText('Anna')).toBeInTheDocument() expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument() await act(async () => resolveStatus(jsonResponse({ ...assignedTask, status: 'IN_PROGRESS' })), ) }) test('drop i samma kolumn är no-op', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = mockUsersAndTasks(users, [tasks[0]]) render() await screen.findByText('Dammsuga') act(() => dropTask(tasks[0].id, 'WAITING')) expect(fetchMock).toHaveBeenCalledTimes(2) expect(screen.getByText('Dammsuga').closest('article')).not.toHaveAttribute('aria-busy') }) test('olika kort kan ha samtidiga optimistiska statusanrop', async () => { const otherTask = { ...tasks[0], id: '00000000-0000-0000-0000-000000000010', title: 'Putsa fönster', } const firstResponse = new Promise(() => {}) const secondResponse = new Promise(() => {}) window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask])) fetchMock.mockReturnValueOnce(firstResponse) fetchMock.mockReturnValueOnce(secondResponse) render() await screen.findByText('Dammsuga') act(() => { dropTask(tasks[0].id, 'IN_PROGRESS') dropTask(otherTask.id, 'COMPLETED') }) expect(screen.getByText('Dammsuga').closest('article')).toHaveAttribute('aria-busy', 'true') expect(screen.getByText('Putsa fönster').closest('article')).toHaveAttribute( 'aria-busy', 'true', ) expect(fetchMock).toHaveBeenCalledTimes(4) }) test('ansvarig kan bytas i Pågående och tas bort i Klart', async () => { const changedInProgress = { ...tasks[1], assignee: { id: users[0].id, name: users[0].name } } const unassignedCompleted = { ...tasks[2], assignee: null } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[1], { ...tasks[2], assignee: tasks[1].assignee }])) fetchMock.mockResolvedValueOnce(jsonResponse(changedInProgress)) fetchMock.mockResolvedValueOnce(jsonResponse(unassignedCompleted)) render() const inProgressCard = (await screen.findByText('Diska')).closest('article')! fireEvent.click( within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' }), ) fireEvent.change(within(inProgressCard).getByRole('combobox'), { target: { value: users[0].id }, }) await waitFor(() => expect(within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' })) .toHaveTextContent('Urban'), ) const completedCard = screen.getByText('Vattna blommor').closest('article')! fireEvent.click( within(completedCard).getByRole('button', { name: 'Ändra ansvarig för Vattna blommor', }), ) fireEvent.change(within(completedCard).getByRole('combobox'), { target: { value: '' } }) await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[2].id}/assignee`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assigneeId: null }), }), ) }) test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => { const updatedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } } let resolveAssignment!: (response: Response) => void const assignmentResponse = new Promise((resolve) => { resolveAssignment = resolve }) window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) fetchMock.mockReturnValueOnce(assignmentResponse) render() fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) const select = screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }) fireEvent.change(select, { target: { value: users[1].id } }) expect(select).toBeDisabled() expect(within(select.closest('article')!).getByRole('button', { name: 'Påbörja' })) .toBeDisabled() expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assigneeId: users[1].id }), }) resolveAssignment(jsonResponse(updatedTask)) await waitFor(() => expect(screen.getByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) .toHaveTextContent('Anna'), ) expect(screen.queryByRole('combobox', { name: 'Ansvarig för Dammsuga' })).not.toBeInTheDocument() }) test('val av Ingen av-tilldelar en väntande uppgift', async () => { const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } } const unassignedTask = { ...assignedTask, assignee: null } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask])) fetchMock.mockResolvedValueOnce(jsonResponse(unassignedTask)) render() fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), { target: { value: '' }, }) await screen.findByText('Ta uppgift') expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assigneeId: null }), }) }) test('misslyckad tilldelning behåller ansvarig och visar fel', async () => { const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } } window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask])) fetchMock.mockResolvedValueOnce( jsonResponse({ code: 'USER_NOT_FOUND', message: 'Användaren finns inte.' }, 404), ) render() fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' })) fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), { target: { value: users[0].id }, }) expect(await screen.findByRole('alert')).toHaveTextContent('Användaren finns inte.') expect(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })).toHaveValue(users[1].id) }) test.each([ { värde: '', beskrivning: 'tomt' }, { värde: '0', beskrivning: 'under 1' }, { värde: '100', beskrivning: 'över 99' }, { värde: '1.5', beskrivning: 'decimaltal' }, ])('ogiltigt poängvärde ($beskrivning) blockerar submit', async ({ värde }) => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = mockUsersAndTasks(users, []) render() await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } }) fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: värde } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) expect(await screen.findByRole('alert')).toHaveTextContent( 'Poäng måste vara ett heltal mellan 1 och 99.', ) expect(fetchMock).toHaveBeenCalledTimes(2) }) test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(users)) fetchMock.mockResolvedValueOnce(jsonResponse([])) fetchMock.mockResolvedValueOnce( jsonResponse({ code: 'INVALID_TASK', message: 'Uppgiften är ogiltig.' }, 400), ) render() await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) const title = screen.getByLabelText('Titel') const description = screen.getByLabelText('Beskrivning (valfri)') const points = screen.getByLabelText('Poäng') const assignee = screen.getByLabelText('Tilldela') fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) fireEvent.change(points, { target: { value: '7' } }) fireEvent.change(assignee, { target: { value: users[1].id } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(title).toHaveValue('Dammsuga') expect(description).toHaveValue('Bottenvåningen') expect(points).toHaveValue(7) expect(assignee).toHaveValue(users[1].id) }) function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse(userResponse)) fetchMock.mockResolvedValueOnce(jsonResponse(taskResponse)) return fetchMock } function dropTask(taskId: string, status: string) { if (!dragAndDrop.onTaskDrop) { throw new Error('Drag-and-drop-providern är inte monterad') } dragAndDrop.onTaskDrop(taskId, status) } function mockJsonResponse(body: unknown, status = 200) { return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status)) } function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) }