test(time-tracking): add end-to-end coverage
This commit is contained in:
parent
2d334e56c7
commit
4390af4773
|
|
@ -0,0 +1,323 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import type {Page, Locator} from '@playwright/test'
|
||||
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {TimeEntryFactory} from '../../factories/time_entry'
|
||||
import {LicenseFactory} from '../../factories/license'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
|
||||
// Pick a project in the form's project picker. Waits for the project store to
|
||||
// hydrate (the sidebar shows it) before searching so the result is there.
|
||||
async function selectProject(page: Page, form: Locator, title: string) {
|
||||
await expect(page.locator('.menu-container').getByText(title)).toBeVisible()
|
||||
const input = form.locator('.multiselect').first().locator('input')
|
||||
await input.click()
|
||||
// pressSequentially (not fill) so the multiselect's @keyup search fires.
|
||||
await input.pressSequentially(title, {delay: 30})
|
||||
await form.locator('.search-result-button').filter({hasText: title}).first().click()
|
||||
}
|
||||
|
||||
// Pick a task in the form's task picker (the second multiselect, after project).
|
||||
async function selectTask(form: Locator, title: string) {
|
||||
const input = form.locator('.multiselect').nth(1).locator('input')
|
||||
await input.click()
|
||||
await input.pressSequentially(title, {delay: 30})
|
||||
await form.locator('.search-result-button').filter({hasText: title}).first().click()
|
||||
}
|
||||
|
||||
// Open the time-tracking section on a task detail page.
|
||||
async function openTaskTimeTracking(page: Page, taskId: number): Promise<Locator> {
|
||||
await page.goto(`/tasks/${taskId}`)
|
||||
await page.locator('[data-cy="taskTrackTimeAction"]').click()
|
||||
const section = page.locator('.task-time-tracking')
|
||||
await expect(section).toBeVisible()
|
||||
return section
|
||||
}
|
||||
|
||||
test.describe('Time tracking', () => {
|
||||
test.describe('with the feature licensed', () => {
|
||||
test.beforeEach(async () => {
|
||||
await LicenseFactory.enable(['time_tracking'])
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await LicenseFactory.disable()
|
||||
})
|
||||
|
||||
test('shows the page and the sidebar entry', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toBeVisible()
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('logs a manual time entry', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'E2E tracked project'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
await selectProject(page, form, 'E2E tracked project')
|
||||
// Smart-fill populates both from and to, so the entry is complete.
|
||||
await form.locator('[data-cy="smartFill"]').click()
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
|
||||
await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'E2E tracked project'})).toBeVisible()
|
||||
})
|
||||
|
||||
test('saving with an empty To logs a completed entry, not a running timer', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'E2E save project'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
await selectProject(page, form, 'E2E save project')
|
||||
// No smart-fill: leave "To" empty, then Save.
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
|
||||
// The entry is completed (no open-ended "…") and no timer started.
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).not.toContainText('…')
|
||||
await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('switching from a task to a project logs against the project', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'XOR project'}, false)
|
||||
await TaskFactory.create(1, {id: 1, title: 'XOR task', project_id: 1}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
|
||||
// Pick a task, then change your mind to a project — the task must be cleared.
|
||||
await selectTask(form, 'XOR task')
|
||||
await selectProject(page, form, 'XOR project')
|
||||
|
||||
await form.locator('[data-cy="smartFill"]').click()
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
|
||||
const entry = page.locator('[data-cy="timeEntry"]').first()
|
||||
await expect(entry).toContainText('XOR project')
|
||||
await expect(entry).not.toContainText('XOR task')
|
||||
})
|
||||
|
||||
test('starts a timer and stopping it updates the same entry in the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'E2E timer project'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
await selectProject(page, form, 'E2E timer project')
|
||||
await form.locator('[data-cy="startTimer"]').click()
|
||||
|
||||
const badge = page.locator('[data-cy="timerBadge"]')
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
// The running entry is in the list with an open-ended time range.
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('…')
|
||||
|
||||
await badge.locator('[data-cy="stopTimer"]').click()
|
||||
await expect(badge).not.toBeVisible()
|
||||
|
||||
// The same entry is updated in place — end time set, no longer open-ended.
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).not.toContainText('…')
|
||||
})
|
||||
|
||||
test('does not show another user\'s readable running timer in the header', async ({
|
||||
authenticatedPage: page,
|
||||
currentUser,
|
||||
}) => {
|
||||
const [timerOwner] = await UserFactory.create(1, {id: currentUser.id + 100}, false)
|
||||
const [sharedProject] = await ProjectFactory.create(1, {
|
||||
id: 1001,
|
||||
title: 'Shared active timer project',
|
||||
owner_id: timerOwner.id,
|
||||
}, false)
|
||||
await UserProjectFactory.create(1, {
|
||||
project_id: sharedProject.id,
|
||||
user_id: currentUser.id,
|
||||
permission: 0,
|
||||
}, false)
|
||||
await TimeEntryFactory.create(1, {
|
||||
project_id: sharedProject.id,
|
||||
user_id: timerOwner.id,
|
||||
end_time: null,
|
||||
comment: 'other user running timer',
|
||||
}, false)
|
||||
|
||||
const activeTimerHydrated = page.waitForResponse(response =>
|
||||
response.request().method() === 'GET' &&
|
||||
response.url().includes('/api/v2/time-entries') &&
|
||||
response.url().includes('per_page=1'),
|
||||
)
|
||||
await page.goto('/time-tracking')
|
||||
await activeTimerHydrated
|
||||
|
||||
await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'other user running timer'})).toBeVisible()
|
||||
await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('hides edit/delete on entries owned by another user', async ({authenticatedPage: page, currentUser}) => {
|
||||
const [other] = await UserFactory.create(1, {id: currentUser.id + 100}, false)
|
||||
const [shared] = await ProjectFactory.create(1, {id: 2001, title: 'Shared log project', owner_id: other.id}, false)
|
||||
await UserProjectFactory.create(1, {project_id: shared.id, user_id: currentUser.id, permission: 0}, false)
|
||||
await TimeEntryFactory.create(1, {id: 10, project_id: shared.id, user_id: other.id, comment: 'theirs'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 11, project_id: shared.id, user_id: currentUser.id, comment: 'mine'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const theirs = page.locator('[data-cy="timeEntry"]').filter({hasText: 'theirs'})
|
||||
const mine = page.locator('[data-cy="timeEntry"]').filter({hasText: 'mine'})
|
||||
await expect(theirs).toBeVisible()
|
||||
await expect(mine).toBeVisible()
|
||||
|
||||
// The current user keeps the controls on their own entry, but not the other's.
|
||||
await expect(mine.locator('[data-cy="editTimeEntry"]')).toBeVisible()
|
||||
await expect(theirs.locator('[data-cy="editTimeEntry"]')).toHaveCount(0)
|
||||
await expect(theirs.locator('[data-cy="deleteTimeEntry"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('task detail: logs an entry and toggles the form with the + button', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'P'}, false)
|
||||
await TaskFactory.create(1, {title: 'Tracked task', project_id: 1}, false)
|
||||
|
||||
const section = await openTaskTimeTracking(page, 1)
|
||||
const form = section.locator('[data-cy="timeEntryForm"]')
|
||||
|
||||
// No entries yet → the form is shown implicitly.
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
await form.locator('[data-cy="smartFill"]').click()
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
await expect(section.locator('[data-cy="timeEntry"]')).toHaveCount(1)
|
||||
|
||||
// With an entry, the form collapses behind the + button.
|
||||
await expect(form).not.toBeVisible()
|
||||
const addButton = section.locator('[data-cy="addTaskTimeEntry"]')
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
await expect(form).toBeVisible()
|
||||
})
|
||||
|
||||
test('task detail: stopping a timer updates the entry in the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'P'}, false)
|
||||
await TaskFactory.create(1, {title: 'Timed task', project_id: 1}, false)
|
||||
|
||||
const section = await openTaskTimeTracking(page, 1)
|
||||
await section.locator('[data-cy="timeEntryForm"] [data-cy="startTimer"]').click()
|
||||
|
||||
const badge = page.locator('[data-cy="timerBadge"]')
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
const entries = section.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('…')
|
||||
|
||||
await badge.locator('[data-cy="stopTimer"]').click()
|
||||
await expect(badge).not.toBeVisible()
|
||||
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).not.toContainText('…')
|
||||
})
|
||||
|
||||
test('edits an entry from the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'Edit project'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'original comment'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('original comment')
|
||||
|
||||
await entries.first().locator('[data-cy="editTimeEntry"]').click()
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
const comment = form.locator('[data-cy="timeEntryComment"]')
|
||||
await expect(comment).toHaveValue('original comment')
|
||||
await comment.fill('edited comment')
|
||||
await form.locator('[data-cy="updateTimeEntry"]').click()
|
||||
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('edited comment')
|
||||
await expect(entries.first()).not.toContainText('original comment')
|
||||
})
|
||||
|
||||
test('deletes an entry from the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'Delete project'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'to be deleted'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
|
||||
await entries.first().locator('[data-cy="deleteTimeEntry"]').click()
|
||||
await expect(entries).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('filters by project, reflected in the url and restored on reload', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'Alpha'}, false)
|
||||
await ProjectFactory.create(1, {id: 2, title: 'Beta'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'alpha entry'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 2, project_id: 2, comment: 'beta entry'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(2)
|
||||
|
||||
// Narrow to project Alpha in the filter modal.
|
||||
await page.locator('[data-cy="openTimeTrackingFilters"]').click()
|
||||
const dialog = page.locator('dialog[open]')
|
||||
const projectInput = dialog.locator('.multiselect').first().locator('input')
|
||||
await projectInput.click()
|
||||
await projectInput.pressSequentially('Alpha', {delay: 30})
|
||||
await dialog.locator('.search-result-button').filter({hasText: 'Alpha'}).first().click()
|
||||
|
||||
// The filter is written to the url.
|
||||
await expect(page).toHaveURL(/[?&]project=1\b/)
|
||||
|
||||
// ...and survives a reload (restored from the url): only Alpha's entry.
|
||||
await page.reload()
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('Alpha')
|
||||
await expect(page).toHaveURL(/[?&]project=1\b/)
|
||||
})
|
||||
|
||||
test('clearing the date range does not crash the page', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/time-tracking')
|
||||
// The default range surfaces as "Today" in the toolbar label.
|
||||
await expect(page.locator('.time-tracking__range')).toHaveText('Today')
|
||||
|
||||
await page.locator('[data-cy="openTimeTrackingFilters"]').click()
|
||||
// Open the range popup (its trigger is the first button in the picker) and clear via Custom.
|
||||
await page.locator('dialog[open] .datepicker-with-range-container').getByRole('button').first().click()
|
||||
await page.getByRole('button', {name: 'Custom', exact: true}).click()
|
||||
|
||||
// rangeLabel must not call getFullYear on a null date — the page stays alive.
|
||||
await expect(page.locator('.time-tracking__range')).toHaveText('Select a range')
|
||||
await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('without the feature licensed', () => {
|
||||
test.beforeEach(async () => {
|
||||
await LicenseFactory.disable()
|
||||
})
|
||||
|
||||
test('hides the sidebar entry and blocks the route', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toHaveCount(0)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await expect(page.locator('[data-cy="addTimeEntry"]')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
// Local "YYYY-MM-DD HH:MM:SS" (the format the DB fixtures use), not ISO-with-Z.
|
||||
// start_time is filtered with datemath day windows that resolve to local time,
|
||||
// and the comparison is lexical — a UTC-stamped value falls outside "today"
|
||||
// near midnight.
|
||||
function sqlDateTime(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export class TimeEntryFactory extends Factory {
|
||||
static table = 'time_entries'
|
||||
|
||||
static factory() {
|
||||
const now = sqlDateTime(new Date())
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
user_id: 1,
|
||||
task_id: 0,
|
||||
project_id: 0,
|
||||
// Completed by default (end set), within today so the default filter shows it.
|
||||
start_time: now,
|
||||
end_time: now,
|
||||
comment: '',
|
||||
created: now,
|
||||
updated: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue