mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
/**
|
||
* E2E coverage for the timesheet overlap-prevention logic introduced
|
||
* in `useTimesheetCellMutations` (Phase 1+2+3 of the overlap fix).
|
||
*
|
||
* Each test:
|
||
* 1. Pre-creates entries via the API to set up a deterministic
|
||
* day-of-work scenario,
|
||
* 2. Triggers ONE cell edit through the UI,
|
||
* 3. Reads the resulting entries back via the API and asserts on
|
||
* the start/end placement.
|
||
*
|
||
* Pre-creating rows (rather than driving the "Add row" + project picker
|
||
* UI) keeps the tests focused on the placement logic and out of the
|
||
* project-dropdown's flake surface.
|
||
*/
|
||
|
||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||
import { test } from '../playwright/fixtures';
|
||
import { expect } from '@playwright/test';
|
||
import type { Page, Request } from '@playwright/test';
|
||
import {
|
||
createProjectViaApi,
|
||
createTimeEntryAtHourViaApi,
|
||
getTimeEntriesViaApi,
|
||
} from './utils/api';
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Helpers
|
||
// ──────────────────────────────────────────────────
|
||
|
||
async function goToTimesheet(page: Page) {
|
||
await page.addInitScript(() => {
|
||
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
|
||
});
|
||
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
|
||
}
|
||
|
||
function getMonday(d: Date): Date {
|
||
const date = new Date(d);
|
||
const day = date.getUTCDay();
|
||
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
|
||
date.setUTCDate(diff);
|
||
date.setUTCHours(0, 0, 0, 0);
|
||
return date;
|
||
}
|
||
|
||
function getCurrentWeekMonday(): Date {
|
||
return getMonday(new Date());
|
||
}
|
||
|
||
async function waitForTimesheetLoad(page: Page) {
|
||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
|
||
|
||
const timezoneMismatchModal = page
|
||
.getByRole('dialog')
|
||
.filter({ hasText: 'Timezone mismatch detected' });
|
||
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
|
||
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
|
||
await expect(timezoneMismatchModal).not.toBeVisible();
|
||
}
|
||
}
|
||
|
||
const HOUR = 3600;
|
||
|
||
function utcHourOf(iso: string): number {
|
||
return new Date(iso).getUTCHours();
|
||
}
|
||
|
||
function utcMinuteOf(iso: string): number {
|
||
return new Date(iso).getUTCMinutes();
|
||
}
|
||
|
||
function sortByStart<T extends { start: string }>(entries: T[]): T[] {
|
||
return [...entries].sort((a, b) => a.start.localeCompare(b.start));
|
||
}
|
||
|
||
/**
|
||
* Returns the locator for the row whose project name matches the given
|
||
* substring. Robust against ordering changes.
|
||
*/
|
||
function rowByProject(page: Page, projectName: string) {
|
||
return page.locator('[data-testid="timesheet_row"]').filter({ hasText: projectName });
|
||
}
|
||
|
||
/**
|
||
* Returns the locator for the input in the (row, dayIndex) cell, where
|
||
* the row is identified by project name.
|
||
*/
|
||
function cellInputByProject(page: Page, projectName: string, dayIndex: number) {
|
||
return rowByProject(page, projectName)
|
||
.locator('[data-testid="timesheet_cell"]')
|
||
.nth(dayIndex)
|
||
.locator('input');
|
||
}
|
||
|
||
/** Asserts that no entries in the list overlap each other. */
|
||
function expectNoOverlaps(entries: Array<{ start: string; end: string | null }>) {
|
||
const sorted = sortByStart(entries.filter((e) => e.end !== null));
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
const prev = sorted[i - 1]!;
|
||
const curr = sorted[i]!;
|
||
expect(
|
||
curr.start >= prev.end!,
|
||
`entries overlap: ${prev.start}–${prev.end} vs ${curr.start}–${curr.end}`
|
||
).toBe(true);
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Phase 1: createCell — overlap avoidance when cell is empty
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('extendCell on a row that has no entries on the day yet places after another row (Scenario #4)', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Setup: project A has Monday 09:00–10:00, project B has Tuesday
|
||
// 09:00–10:00. The B row is therefore visible on the timesheet but
|
||
// has an EMPTY cell on Monday. Typing into B's Monday cell exercises
|
||
// the createCell path (cell empty → place a new entry).
|
||
const monday = getCurrentWeekMonday();
|
||
const tuesday = new Date(monday);
|
||
tuesday.setUTCDate(monday.getUTCDate() + 1);
|
||
|
||
const projectA = await createProjectViaApi(ctx, { name: 'OverlapAlpha' });
|
||
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBravo' });
|
||
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 9,
|
||
durationSeconds: HOUR,
|
||
projectId: projectA.id,
|
||
});
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: tuesday,
|
||
startHour: 9,
|
||
durationSeconds: HOUR,
|
||
projectId: projectB.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
|
||
|
||
// Type 1h into project B's Monday cell. The createCell path should
|
||
// place it AFTER project A's 09:00–10:00 (i.e. at 10:00 or later),
|
||
// not at 09:00.
|
||
const input = cellInputByProject(page, 'OverlapBravo', 0);
|
||
await input.click();
|
||
await input.fill('1');
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201
|
||
),
|
||
input.press('Enter'),
|
||
]);
|
||
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const bMondayEntry = entries.find(
|
||
(e) =>
|
||
e.project_id === projectB.id &&
|
||
new Date(e.start).getTime() >= monday.getTime() &&
|
||
new Date(e.start).getTime() < tuesday.getTime()
|
||
)!;
|
||
expect(bMondayEntry).toBeDefined();
|
||
// 09:00 is blocked → must be at 10:00 or later.
|
||
expect(utcHourOf(bMondayEntry.start)).toBeGreaterThanOrEqual(10);
|
||
expectNoOverlaps(entries);
|
||
});
|
||
|
||
test('createCell refuses to cross midnight when day is full (Scenario #3)', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Setup: fill Monday 01:00–23:00 (22 hours, leaving 1h before and
|
||
// 1h after — neither big enough for a 3h ask). Project B is on
|
||
// Tuesday so the B row exists with an empty Monday cell. Typing 3h
|
||
// into B's Monday cell should be refused.
|
||
//
|
||
// We start at 01:00 (not 00:00) because the API's time-entry
|
||
// filter excludes entries whose `start` equals the query's `start`
|
||
// bound exactly. Using 01:00 avoids that boundary condition.
|
||
const monday = getCurrentWeekMonday();
|
||
const tuesday = new Date(monday);
|
||
tuesday.setUTCDate(monday.getUTCDate() + 1);
|
||
|
||
const projectFull = await createProjectViaApi(ctx, { name: 'OverlapFull' });
|
||
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapNoRoom' });
|
||
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 1,
|
||
durationSeconds: 22 * HOUR,
|
||
projectId: projectFull.id,
|
||
});
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: tuesday,
|
||
startHour: 9,
|
||
durationSeconds: HOUR,
|
||
projectId: projectNew.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
|
||
|
||
const input = cellInputByProject(page, 'OverlapNoRoom', 0);
|
||
const seenMutationRequests: string[] = [];
|
||
const onRequest = (request: Request) => {
|
||
if (request.url().includes('/time-entries') && request.method() !== 'GET') {
|
||
seenMutationRequests.push(request.method());
|
||
}
|
||
};
|
||
page.on('request', onRequest);
|
||
await input.click();
|
||
await input.fill('3');
|
||
await input.press('Enter');
|
||
|
||
await expect(page.getByText("This day can't fit any more work")).toBeVisible();
|
||
page.off('request', onRequest);
|
||
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
// The new project should still only have its Tuesday entry.
|
||
const newEntries = entries.filter((e) => e.project_id === projectNew.id);
|
||
expect(seenMutationRequests).toEqual([]);
|
||
expect(newEntries).toHaveLength(1);
|
||
expect(utcHourOf(newEntries[0]!.start)).toBe(9);
|
||
// The Tuesday entry's date is unchanged (still Tuesday).
|
||
expect(new Date(newEntries[0]!.start).getUTCDay()).toBe(2);
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Phase 2: extendCell — collision detection + split
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('extendCell splits the extension when another row blocks the path (Scenario #5)', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Setup:
|
||
// - project A on Monday 09:00–10:00 (1h)
|
||
// - project B on Monday 10:30–11:30 (1h, blocker)
|
||
// Bumping A's Monday cell from 1h to 3h (+2h) should:
|
||
// - extend A to 09:00–10:30 (filling the 30min gap)
|
||
// - place a new A entry at 11:30–13:00 (the remaining 90min)
|
||
const monday = getCurrentWeekMonday();
|
||
const projectA = await createProjectViaApi(ctx, { name: 'OverlapExtend' });
|
||
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBlocker' });
|
||
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 9,
|
||
durationSeconds: HOUR,
|
||
projectId: projectA.id,
|
||
});
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 10,
|
||
startMinute: 30,
|
||
durationSeconds: HOUR,
|
||
projectId: projectB.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
|
||
|
||
const input = cellInputByProject(page, 'OverlapExtend', 0);
|
||
await input.click();
|
||
await input.fill('3');
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'PUT' &&
|
||
resp.status() === 200
|
||
),
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201
|
||
),
|
||
input.press('Enter'),
|
||
]);
|
||
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const aEntries = entries.filter((e) => e.project_id === projectA.id);
|
||
const bEntries = entries.filter((e) => e.project_id === projectB.id);
|
||
|
||
// The blocker is unchanged.
|
||
expect(bEntries).toHaveLength(1);
|
||
expect(utcHourOf(bEntries[0]!.start)).toBe(10);
|
||
expect(utcMinuteOf(bEntries[0]!.start)).toBe(30);
|
||
|
||
// Project A should now have 2 entries.
|
||
expect(aEntries).toHaveLength(2);
|
||
const sortedA = sortByStart(aEntries);
|
||
// Extended entry: 09:00 → 10:30
|
||
expect(utcHourOf(sortedA[0]!.start)).toBe(9);
|
||
expect(utcHourOf(sortedA[0]!.end!)).toBe(10);
|
||
expect(utcMinuteOf(sortedA[0]!.end!)).toBe(30);
|
||
// Split remainder: 11:30 → 13:00
|
||
expect(utcHourOf(sortedA[1]!.start)).toBe(11);
|
||
expect(utcMinuteOf(sortedA[1]!.start)).toBe(30);
|
||
|
||
// No overlaps anywhere on the day.
|
||
expectNoOverlaps(entries);
|
||
});
|
||
|
||
test('extendCell prefers latest-end (not latest-start) when nested entries exist (Scenario #6)', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Pre-existing nested overlap on the same project:
|
||
// - outer: 09:00 → 12:00 (3h)
|
||
// - inner: 10:00 → 11:00 (1h, contained inside outer)
|
||
// The cell total is 3h + 1h = 4h. Bumping to 5h (+1h) should grow
|
||
// the OUTER entry's end to 13:00, not the inner.
|
||
const monday = getCurrentWeekMonday();
|
||
const project = await createProjectViaApi(ctx, { name: 'OverlapNested' });
|
||
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 9,
|
||
durationSeconds: 3 * HOUR,
|
||
projectId: project.id,
|
||
description: 'outer',
|
||
});
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 10,
|
||
durationSeconds: HOUR,
|
||
projectId: project.id,
|
||
description: 'inner',
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(1);
|
||
|
||
const input = cellInputByProject(page, 'OverlapNested', 0);
|
||
await input.click();
|
||
await input.fill('5');
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'PUT' &&
|
||
resp.status() === 200
|
||
),
|
||
input.press('Enter'),
|
||
]);
|
||
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const outer = entries.find((e) => e.description === 'outer')!;
|
||
const inner = entries.find((e) => e.description === 'inner')!;
|
||
|
||
expect(utcHourOf(outer.start)).toBe(9);
|
||
expect(utcHourOf(outer.end!)).toBe(13); // extended from 12:00 → 13:00
|
||
expect(utcHourOf(inner.start)).toBe(10);
|
||
expect(utcHourOf(inner.end!)).toBe(11); // unchanged
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────
|
||
// Phase 1+2 spillover from previous day
|
||
// ──────────────────────────────────────────────────
|
||
|
||
test('createCell handles intra-week spillover from previous day (Scenario #2)', async ({
|
||
page,
|
||
ctx,
|
||
}) => {
|
||
// Setup: an entry that starts on Monday 22:00 and ends Tuesday 03:00
|
||
// (5h, crosses midnight INTO Tuesday). This spillover starts inside
|
||
// the loaded week, so the timesheet query loads it.
|
||
//
|
||
// Then we try to place 1h on Tuesday for a different project. The
|
||
// expected behavior: the new entry must NOT overlap the spillover.
|
||
// Tuesday 09:00 is well clear of the [00:00, 03:00) spillover, so
|
||
// 09:00 is the correct placement.
|
||
const monday = getCurrentWeekMonday();
|
||
const tuesday = new Date(monday);
|
||
tuesday.setUTCDate(monday.getUTCDate() + 1);
|
||
const wednesday = new Date(monday);
|
||
wednesday.setUTCDate(monday.getUTCDate() + 2);
|
||
|
||
const projectSpill = await createProjectViaApi(ctx, { name: 'OverlapSpill' });
|
||
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapToday' });
|
||
|
||
// Monday 22:00 → Tuesday 03:00 (5h spillover into Tuesday).
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: monday,
|
||
startHour: 22,
|
||
durationSeconds: 5 * HOUR,
|
||
projectId: projectSpill.id,
|
||
});
|
||
// Stub Wednesday entry on the new project so its row is visible
|
||
// even before we type anything in Tuesday's cell.
|
||
await createTimeEntryAtHourViaApi(ctx, {
|
||
date: wednesday,
|
||
startHour: 9,
|
||
durationSeconds: HOUR,
|
||
projectId: projectNew.id,
|
||
});
|
||
|
||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
|
||
|
||
// Type 1h into the new project's Tuesday cell (day index 1).
|
||
const input = cellInputByProject(page, 'OverlapToday', 1);
|
||
await input.click();
|
||
await input.fill('1');
|
||
|
||
await Promise.all([
|
||
page.waitForResponse(
|
||
(resp) =>
|
||
resp.url().includes('/time-entries') &&
|
||
resp.request().method() === 'POST' &&
|
||
resp.status() === 201
|
||
),
|
||
input.press('Enter'),
|
||
]);
|
||
|
||
const entries = await getTimeEntriesViaApi(ctx);
|
||
const newTuesdayEntry = entries.find(
|
||
(e) =>
|
||
e.project_id === projectNew.id &&
|
||
new Date(e.start).getTime() >= tuesday.getTime() &&
|
||
new Date(e.start).getTime() < wednesday.getTime()
|
||
)!;
|
||
expect(newTuesdayEntry).toBeDefined();
|
||
// 09:00 is well past the spillover end (03:00) → should land at 09:00.
|
||
expect(utcHourOf(newTuesdayEntry.start)).toBe(9);
|
||
expectNoOverlaps(entries);
|
||
});
|