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' },
})
}