mirror of
https://github.com/iritmaximus/workh.git
synced 2026-10-12 19:13:33 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// @vitest-environment jsdom
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Data } from '../../shared/types';
|
||||
import { App } from '../../frontend/App';
|
||||
import { installFakeApi } from './fakeApi';
|
||||
|
||||
// Thursday of ISO week 41; the tracked week runs Mon 2026-10-05 – Sun 2026-10-11.
|
||||
const NOW = new Date('2026-10-08T12:00:00');
|
||||
|
||||
const data = (): Data => ({
|
||||
settings: {
|
||||
fullTimeWeekMinutes: 2175,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
trackingStart: '2026-10-05',
|
||||
openingBalanceMinutes: 0,
|
||||
},
|
||||
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
|
||||
entries: {
|
||||
'2026-10-05': { mode: 'total', minutes: 435 },
|
||||
'2026-10-06': { mode: 'total', minutes: 435 },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: NOW });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function logIn(user = userEvent.setup(), password = 'secret password', username = 'aaro') {
|
||||
await user.type(await screen.findByLabelText('Username'), username);
|
||||
await user.type(screen.getByLabelText('Password'), password);
|
||||
await user.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
}
|
||||
|
||||
const balanceCard = () => document.querySelector('section.balance') as HTMLElement;
|
||||
const weekFooter = () => document.querySelector('.week-total') as HTMLElement;
|
||||
|
||||
describe('login', () => {
|
||||
it('shows the login form when there is no session', async () => {
|
||||
const { fetchMock } = installFakeApi({ data: data() });
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/data', expect.anything());
|
||||
});
|
||||
|
||||
it('shows an error for a wrong password and stays on the form', async () => {
|
||||
installFakeApi({ data: data() });
|
||||
render(<App />);
|
||||
await logIn(undefined, 'wrong');
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong username or password');
|
||||
expect(screen.getByLabelText('Password')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('loads the tracker after logging in', async () => {
|
||||
installFakeApi({ data: data() });
|
||||
render(<App />);
|
||||
await logIn();
|
||||
expect(await screen.findByRole('heading', { name: /Week 41/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('aaro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips the form with an existing session, and logs out', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
await user.click(screen.getByRole('button', { name: 'Log out' }));
|
||||
expect(await screen.findByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||||
expect(state.session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracking hours', () => {
|
||||
it('shows the week totals and the running balance up to today', async () => {
|
||||
installFakeApi({ data: data(), loggedIn: true });
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
// Two 7:15 days logged; full-week target 36:15.
|
||||
expect(within(weekFooter()).getByText('14 h 30 min')).toBeInTheDocument();
|
||||
expect(within(weekFooter()).getByText('36 h 15 min')).toBeInTheDocument();
|
||||
// Balance counts Mon–Thu (today): Wed and Thu missing -> −14 h 30 min.
|
||||
expect(within(balanceCard()).getByText('−14 h 30 min')).toBeInTheDocument();
|
||||
expect(within(balanceCard()).getByText('hours short')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates totals as you type and saves to the server', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
|
||||
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '8:15');
|
||||
await user.type(screen.getByLabelText('Hours worked on 2026-10-08'), '7h15');
|
||||
|
||||
expect(within(balanceCard()).getByText('+1 h 00 min')).toBeInTheDocument();
|
||||
expect(within(balanceCard()).getByText('extra hours banked')).toBeInTheDocument();
|
||||
await waitFor(() => expect(state.data.entries['2026-10-08']).toEqual({ mode: 'total', minutes: 435 }));
|
||||
expect(state.data.entries['2026-10-07']).toEqual({ mode: 'total', minutes: 495 });
|
||||
expect(await screen.findByText('Saved ✓')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks invalid input without saving it', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
const input = await screen.findByLabelText('Hours worked on 2026-10-07');
|
||||
await user.type(input, 'abc');
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
expect(state.saves).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('counts start–end ranges minus the break', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
|
||||
const wed = screen.getByRole('group', { name: 'Input mode for 2026-10-07' });
|
||||
await user.click(within(wed).getByRole('button', { name: 'Start–End' }));
|
||||
const row = wed.closest('.day') as HTMLElement;
|
||||
await user.type(within(row).getByLabelText('Start'), '08:00');
|
||||
await user.type(within(row).getByLabelText('End'), '16:00');
|
||||
|
||||
expect(within(row).getByText('7:30')).toBeInTheDocument(); // 8 h − 30 min default break
|
||||
await waitFor(() =>
|
||||
expect(state.data.entries['2026-10-07']).toEqual({ mode: 'range', start: '08:00', end: '16:00', breakMin: 30 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives an off day no target', async () => {
|
||||
installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
const wed = await screen.findByRole('group', { name: 'Input mode for 2026-10-07' });
|
||||
await user.click(within(wed).getByRole('button', { name: 'Off' }));
|
||||
expect(within(weekFooter()).getByText('29 h 00 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired session', () => {
|
||||
it('asks to log in again and then saves the pending change', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
|
||||
state.expireSession = true;
|
||||
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
|
||||
|
||||
expect(await screen.findByText(/session expired/i)).toBeInTheDocument();
|
||||
expect(state.data.entries['2026-10-07']).toBeUndefined();
|
||||
|
||||
await logIn(user);
|
||||
await waitFor(() => expect(state.data.entries['2026-10-07']).toEqual({ mode: 'total', minutes: 360 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('creating an account', () => {
|
||||
async function openSignUp(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole('button', { name: 'Create an account' }));
|
||||
}
|
||||
|
||||
it('signs up and lands in an empty tracker', async () => {
|
||||
const { state } = installFakeApi({ data: data() });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openSignUp(user);
|
||||
await user.type(screen.getByLabelText('Username'), 'newbie');
|
||||
await user.type(screen.getByLabelText('Password'), 'long enough password');
|
||||
await user.type(screen.getByLabelText('Repeat password'), 'long enough password');
|
||||
await user.click(screen.getByRole('button', { name: 'Create account' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /Week 41/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('newbie')).toBeInTheDocument();
|
||||
expect(within(weekFooter()).getByText('0 h 00 min')).toBeInTheDocument();
|
||||
expect(state.users.has('newbie')).toBe(true);
|
||||
});
|
||||
|
||||
it('checks the password length and confirmation before sending', async () => {
|
||||
const { fetchMock } = installFakeApi({ data: data() });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openSignUp(user);
|
||||
await user.type(screen.getByLabelText('Username'), 'newbie');
|
||||
await user.type(screen.getByLabelText('Password'), 'short');
|
||||
await user.type(screen.getByLabelText('Repeat password'), 'short');
|
||||
await user.click(screen.getByRole('button', { name: 'Create account' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('at least 12 characters');
|
||||
|
||||
await user.type(screen.getByLabelText('Password'), 'long enough password');
|
||||
await user.type(screen.getByLabelText('Repeat password'), 'a different password');
|
||||
await user.click(screen.getByRole('button', { name: 'Create account' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Passwords do not match');
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/register', expect.anything());
|
||||
});
|
||||
|
||||
it('shows the server error for a taken username', async () => {
|
||||
installFakeApi({ data: data() });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openSignUp(user);
|
||||
await user.type(screen.getByLabelText('Username'), 'aaro');
|
||||
await user.type(screen.getByLabelText('Password'), 'long enough password');
|
||||
await user.type(screen.getByLabelText('Repeat password'), 'long enough password');
|
||||
await user.click(screen.getByRole('button', { name: 'Create account' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('That username is taken');
|
||||
});
|
||||
|
||||
it('asks for an invite code when the server requires one', async () => {
|
||||
installFakeApi({ data: data(), registration: { open: true, inviteRequired: true } });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openSignUp(user);
|
||||
expect(screen.getByLabelText('Invite code')).toBeRequired();
|
||||
});
|
||||
|
||||
it('hides sign-up when registration is closed', async () => {
|
||||
installFakeApi({ data: data(), registration: { open: false, inviteRequired: false } });
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: 'Log in' });
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Create an account' })).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting an account', () => {
|
||||
async function openDelete(user: ReturnType<typeof userEvent.setup>) {
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
await user.click(screen.getByText('Account'));
|
||||
await user.click(screen.getByRole('button', { name: 'Delete account…' }));
|
||||
}
|
||||
|
||||
it('can be cancelled', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openDelete(user);
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.getByRole('button', { name: 'Delete account…' })).toBeInTheDocument();
|
||||
expect(state.users.has('aaro')).toBe(true);
|
||||
});
|
||||
|
||||
it('needs the right password, then deletes and returns to the login screen', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openDelete(user);
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete my account' });
|
||||
expect(deleteButton).toBeDisabled();
|
||||
await user.type(screen.getByLabelText('Enter your password to confirm'), 'wrong');
|
||||
await user.click(deleteButton);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong password');
|
||||
expect(state.users.has('aaro')).toBe(true);
|
||||
|
||||
await user.type(screen.getByLabelText('Enter your password to confirm'), 'secret password');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete my account' }));
|
||||
expect(await screen.findByText('Your account and all its data were deleted.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||||
expect(state.users.has('aaro')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a not-yet-saved change instead of writing it after deletion', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await openDelete(user);
|
||||
// Type a change, then delete before the 500 ms save debounce fires.
|
||||
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
|
||||
await user.type(screen.getByLabelText('Enter your password to confirm'), 'secret password');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete my account' }));
|
||||
await screen.findByText('Your account and all its data were deleted.');
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
expect(state.saves).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired session with another user', () => {
|
||||
it('never saves one user’s pending change into another account', async () => {
|
||||
const { state } = installFakeApi({ data: data(), loggedIn: true });
|
||||
state.users.set('other', { password: 'other password!', data: data() });
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /Week 41/ });
|
||||
|
||||
state.expireSession = true;
|
||||
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
|
||||
await screen.findByText(/session expired/i);
|
||||
|
||||
await logIn(user, 'other password!', 'other');
|
||||
await screen.findByText('other');
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
expect(state.saves.filter((s) => s.username === 'other')).toHaveLength(0);
|
||||
expect(state.users.get('other')!.data.entries['2026-10-07']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { Data } from '../../shared/types';
|
||||
import { Settings } from '../../frontend/components/Settings';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const initial: Data = {
|
||||
settings: {
|
||||
fullTimeWeekMinutes: 2175,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
trackingStart: '2026-10-05',
|
||||
openingBalanceMinutes: 0,
|
||||
},
|
||||
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
|
||||
entries: {},
|
||||
};
|
||||
|
||||
function Harness({ onData }: { onData: (d: Data) => void }) {
|
||||
const [data, setData] = useState(initial);
|
||||
return (
|
||||
<Settings
|
||||
data={data}
|
||||
today="2026-10-08"
|
||||
onChange={(d) => {
|
||||
setData(d);
|
||||
onData(d);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Settings', () => {
|
||||
it('adds a 50% period and shows its weekly hours', async () => {
|
||||
let latest = initial;
|
||||
const user = userEvent.setup();
|
||||
render(<Harness onData={(d) => (latest = d)} />);
|
||||
await user.click(screen.getByText('Settings'));
|
||||
await user.click(screen.getByRole('button', { name: '+ Add period' }));
|
||||
|
||||
expect(latest.workloadPeriods).toEqual([
|
||||
{ from: '2026-01-01', percent: 100 },
|
||||
{ from: '2026-10-08', percent: 50 },
|
||||
]);
|
||||
expect(screen.getByText('= 18:08 h/week')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes a period to 25% and clamps the percentage', async () => {
|
||||
let latest = initial;
|
||||
const user = userEvent.setup();
|
||||
render(<Harness onData={(d) => (latest = d)} />);
|
||||
await user.click(screen.getByText('Settings'));
|
||||
const pct = screen.getByDisplayValue('100');
|
||||
|
||||
fireEvent.change(pct, { target: { value: '25' } });
|
||||
expect(latest.workloadPeriods[0].percent).toBe(25);
|
||||
fireEvent.change(pct, { target: { value: '250' } });
|
||||
expect(latest.workloadPeriods[0].percent).toBe(100);
|
||||
});
|
||||
|
||||
it('accepts a negative opening balance', async () => {
|
||||
let latest = initial;
|
||||
const user = userEvent.setup();
|
||||
render(<Harness onData={(d) => (latest = d)} />);
|
||||
await user.click(screen.getByText('Settings'));
|
||||
const input = screen.getByPlaceholderText('e.g. -2:30');
|
||||
await user.clear(input);
|
||||
await user.type(input, '-2:30');
|
||||
expect(latest.settings.openingBalanceMinutes).toBe(-150);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Data } from '../../shared/types';
|
||||
import {
|
||||
dayTarget,
|
||||
formatDuration,
|
||||
formatHM,
|
||||
history,
|
||||
isoWeekNumber,
|
||||
mondayOf,
|
||||
parseDuration,
|
||||
rangeMinutes,
|
||||
weekSummary,
|
||||
} from '../../frontend/calc';
|
||||
|
||||
// 2026-10-05 is a Monday (ISO week 41).
|
||||
const MON = '2026-10-05';
|
||||
|
||||
function data(overrides: Partial<Data> = {}): Data {
|
||||
return {
|
||||
settings: {
|
||||
fullTimeWeekMinutes: 2175,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
trackingStart: MON,
|
||||
openingBalanceMinutes: 0,
|
||||
},
|
||||
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
|
||||
entries: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const fullWeek = (minutes: number) =>
|
||||
Object.fromEntries(['05', '06', '07', '08', '09'].map((d) => [`2026-10-${d}`, { mode: 'total' as const, minutes }]));
|
||||
|
||||
describe('parseDuration', () => {
|
||||
it.each([
|
||||
['7.5', 450],
|
||||
['7,25', 435],
|
||||
['7:15', 435],
|
||||
['7h15', 435],
|
||||
['7h 15min', 435],
|
||||
['7h', 420],
|
||||
['45m', 45],
|
||||
['-1:30', -90],
|
||||
['8', 480],
|
||||
])('%s -> %i', (input, expected) => expect(parseDuration(input)).toBe(expected));
|
||||
|
||||
it.each(['', 'abc', '7:75', '7h99'])('rejects %j', (input) => expect(parseDuration(input)).toBeNull());
|
||||
});
|
||||
|
||||
describe('formatting', () => {
|
||||
it('formats h:mm and long durations', () => {
|
||||
expect(formatHM(435)).toBe('7:15');
|
||||
expect(formatHM(-90)).toBe('-1:30');
|
||||
expect(formatDuration(65, true)).toBe('+1 h 05 min');
|
||||
expect(formatDuration(-45, true)).toBe('−0 h 45 min');
|
||||
expect(formatDuration(0, true)).toBe('0 h 00 min');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dates', () => {
|
||||
it('finds the Monday and ISO week', () => {
|
||||
expect(mondayOf('2026-10-11')).toBe(MON);
|
||||
expect(mondayOf(MON)).toBe(MON);
|
||||
expect(isoWeekNumber(MON)).toBe(41);
|
||||
expect(isoWeekNumber('2027-01-01')).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMinutes', () => {
|
||||
it('subtracts the break and handles past-midnight shifts', () => {
|
||||
expect(rangeMinutes('08:00', '16:00', 30)).toBe(450);
|
||||
expect(rangeMinutes('22:00', '02:00', 0)).toBe(240);
|
||||
expect(rangeMinutes('', '16:00', 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('targets', () => {
|
||||
it('is 7:15 per weekday at 100% and 0 at weekends', () => {
|
||||
const d = data();
|
||||
expect(dayTarget(MON, d)).toBe(435);
|
||||
expect(dayTarget('2026-10-10', d)).toBe(0);
|
||||
expect(weekSummary(MON, d).target).toBe(2175);
|
||||
});
|
||||
|
||||
it('follows 50% and 25% workload periods', () => {
|
||||
const d = data({
|
||||
workloadPeriods: [
|
||||
{ from: '2026-01-01', percent: 100 },
|
||||
{ from: '2026-10-07', percent: 50 },
|
||||
{ from: '2026-10-09', percent: 25 },
|
||||
],
|
||||
});
|
||||
expect(dayTarget('2026-10-06', d)).toBe(435);
|
||||
expect(dayTarget('2026-10-07', d)).toBe(217.5);
|
||||
expect(dayTarget('2026-10-09', d)).toBe(108.75);
|
||||
expect(weekSummary(MON, d).target).toBe(435 * 2 + 217.5 * 2 + 108.75);
|
||||
});
|
||||
|
||||
it('gives off days a zero target', () => {
|
||||
const d = data({ entries: { [MON]: { mode: 'off', note: 'vacation' } } });
|
||||
expect(dayTarget(MON, d)).toBe(0);
|
||||
expect(weekSummary(MON, d).target).toBe(2175 - 435);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekSummary and history', () => {
|
||||
it('is even for a full 7:15 week', () => {
|
||||
const d = data({ entries: fullWeek(435) });
|
||||
expect(weekSummary(MON, d)).toMatchObject({ worked: 2175, target: 2175, diff: 0 });
|
||||
});
|
||||
|
||||
it('accumulates over/under hours across weeks with an opening balance', () => {
|
||||
const d = data({
|
||||
settings: { ...data().settings, openingBalanceMinutes: 30 },
|
||||
entries: {
|
||||
...fullWeek(495), // +60 min/day -> +5 h
|
||||
'2026-10-12': { mode: 'total', minutes: 375 }, // -60 min
|
||||
},
|
||||
});
|
||||
const { rows, balance } = history(d, '2026-10-12');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ diff: 300, balance: 330 });
|
||||
expect(rows[1]).toMatchObject({ diff: -60, balance: 270 });
|
||||
expect(balance).toBe(270);
|
||||
});
|
||||
|
||||
it('ignores days after today and before the tracking start', () => {
|
||||
const d = data({ settings: { ...data().settings, trackingStart: '2026-10-07' } });
|
||||
// Wed + Thu counted, nothing logged -> -2 × 7:15
|
||||
expect(history(d, '2026-10-08').balance).toBe(-870);
|
||||
});
|
||||
|
||||
it('is empty when tracking starts in the future', () => {
|
||||
expect(history(data(), '2026-10-01')).toEqual({ rows: [], balance: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { vi } from 'vitest';
|
||||
import { defaultData, type Data } from '../../shared/types';
|
||||
|
||||
interface FakeUser {
|
||||
password: string;
|
||||
data: Data;
|
||||
/** Data version, bumped on every save (the server's ETag). */
|
||||
rev?: number;
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the server API, installed as global fetch. */
|
||||
export function installFakeApi(opts: {
|
||||
data: Data;
|
||||
/** Start with this user already logged in (cookie present). */
|
||||
loggedIn?: boolean;
|
||||
registration?: { open: boolean; inviteRequired: boolean };
|
||||
}) {
|
||||
const state = {
|
||||
users: new Map<string, FakeUser>([['aaro', { password: 'secret password', data: structuredClone(opts.data) }]]),
|
||||
session: (opts.loggedIn ? 'aaro' : null) as string | null,
|
||||
saves: [] as { username: string; data: Data }[],
|
||||
/** When true, the next request that needs a session gets 401 (simulates expiry). */
|
||||
expireSession: false,
|
||||
get data() {
|
||||
return this.users.get('aaro')?.data as Data;
|
||||
},
|
||||
};
|
||||
const registration = opts.registration ?? { open: true, inviteRequired: false };
|
||||
|
||||
const reply = (status: number, body?: unknown, headers: Record<string, string> = {}) =>
|
||||
Promise.resolve(
|
||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
}),
|
||||
);
|
||||
const etag = (u: FakeUser) => ({ ETag: `"${u.rev ?? 0}"` });
|
||||
const notLoggedIn = () => reply(401, { error: 'Not logged in' });
|
||||
|
||||
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>((url, init = {}) => {
|
||||
const method = init.method ?? 'GET';
|
||||
const body = init.body ? JSON.parse(init.body as string) : undefined;
|
||||
if (state.expireSession && url !== '/api/login') {
|
||||
state.session = null;
|
||||
state.expireSession = false;
|
||||
}
|
||||
const me = state.session ? state.users.get(state.session) : undefined;
|
||||
|
||||
switch (`${method} ${url}`) {
|
||||
case 'GET /api/registration':
|
||||
return reply(200, registration);
|
||||
case 'POST /api/register': {
|
||||
const name = String(body.username).toLowerCase();
|
||||
if (state.users.has(name)) return reply(409, { error: 'That username is taken' });
|
||||
state.users.set(name, { password: body.password, data: defaultData() });
|
||||
state.session = name;
|
||||
return reply(201, { username: name });
|
||||
}
|
||||
case 'POST /api/login': {
|
||||
const name = String(body.username).toLowerCase();
|
||||
if (state.users.get(name)?.password !== body.password) {
|
||||
return reply(401, { error: 'Wrong username or password' });
|
||||
}
|
||||
state.session = name;
|
||||
return reply(200, { username: name });
|
||||
}
|
||||
case 'POST /api/logout':
|
||||
state.session = null;
|
||||
return reply(204);
|
||||
case 'GET /api/session':
|
||||
return me ? reply(200, { username: state.session }) : notLoggedIn();
|
||||
case 'DELETE /api/account':
|
||||
if (!me) return notLoggedIn();
|
||||
if (body.password !== me.password) return reply(403, { error: 'Wrong password' });
|
||||
state.users.delete(state.session!);
|
||||
state.session = null;
|
||||
return reply(204);
|
||||
case 'GET /api/data':
|
||||
return me ? reply(200, me.data, etag(me)) : notLoggedIn();
|
||||
case 'PUT /api/data': {
|
||||
if (!me) return notLoggedIn();
|
||||
const ifMatch = (init.headers as Record<string, string> | undefined)?.['If-Match'];
|
||||
if (ifMatch !== etag(me).ETag) return reply(412, me.data, etag(me));
|
||||
me.data = body;
|
||||
me.rev = (me.rev ?? 0) + 1;
|
||||
state.saves.push({ username: state.session!, data: body });
|
||||
return reply(200, { ok: true }, etag(me));
|
||||
}
|
||||
default:
|
||||
return reply(404, { error: 'Not found' });
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return { state, fetchMock };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Data } from '../../shared/types';
|
||||
import { mergeData } from '../../frontend/merge';
|
||||
|
||||
const base: Data = {
|
||||
settings: {
|
||||
fullTimeWeekMinutes: 2175,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
trackingStart: '2026-10-05',
|
||||
openingBalanceMinutes: 0,
|
||||
},
|
||||
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
|
||||
entries: { '2026-10-05': { mode: 'total', minutes: 435 } },
|
||||
};
|
||||
|
||||
describe('mergeData', () => {
|
||||
it('keeps changes to different days from both sides', () => {
|
||||
const mine = { ...base, entries: { ...base.entries, '2026-10-06': { mode: 'total' as const, minutes: 420 } } };
|
||||
const theirs = { ...base, entries: { ...base.entries, '2026-10-05': { mode: 'total' as const, minutes: 480 } } };
|
||||
expect(mergeData(base, mine, theirs).entries).toEqual({
|
||||
'2026-10-05': { mode: 'total', minutes: 480 },
|
||||
'2026-10-06': { mode: 'total', minutes: 420 },
|
||||
});
|
||||
});
|
||||
|
||||
it('lets my edit win when both sides changed the same day', () => {
|
||||
const mine = { ...base, entries: { '2026-10-05': { mode: 'total' as const, minutes: 100 } } };
|
||||
const theirs = { ...base, entries: { '2026-10-05': { mode: 'total' as const, minutes: 200 } } };
|
||||
expect(mergeData(base, mine, theirs).entries['2026-10-05']).toEqual({ mode: 'total', minutes: 100 });
|
||||
});
|
||||
|
||||
it('keeps deletions from either side', () => {
|
||||
const mine = { ...base, entries: {} };
|
||||
expect(mergeData(base, mine, base).entries).toEqual({});
|
||||
expect(mergeData(base, base, mine).entries).toEqual({});
|
||||
});
|
||||
|
||||
it('merges settings field by field and periods as a whole', () => {
|
||||
const mine = { ...base, settings: { ...base.settings, openingBalanceMinutes: -60 } };
|
||||
const theirs = {
|
||||
...base,
|
||||
settings: { ...base.settings, trackingStart: '2026-09-28' },
|
||||
workloadPeriods: [...base.workloadPeriods, { from: '2026-11-01', percent: 50 }],
|
||||
};
|
||||
const merged = mergeData(base, mine, theirs);
|
||||
expect(merged.settings).toMatchObject({ openingBalanceMinutes: -60, trackingStart: '2026-09-28' });
|
||||
expect(merged.workloadPeriods).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user