From 3c28c7eaeb1cf091b572ba06f751d133ceec6919 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 25 Jul 2026 11:51:00 +0200 Subject: [PATCH] test(vacay): cover the leave-year window and the comp exclusion Windows for all three year types, the fallbacks (anniversary without a hire date, a day the month cannot have), usage counted across the calendar-year boundary, carry-over chained over periods rather than years, deleteYear over a shifted period, and the reads with and without a viewer. For comp: excluded from usage, a half comp day still costing nothing, converting a vacation day back, and comp_used reported on its own. --- .../tests/unit/services/vacayService.test.ts | 323 +++++++++++++++++- 1 file changed, 320 insertions(+), 3 deletions(-) diff --git a/server/tests/unit/services/vacayService.test.ts b/server/tests/unit/services/vacayService.test.ts index 9d84f5dc1..9f1c3e9a3 100644 --- a/server/tests/unit/services/vacayService.test.ts +++ b/server/tests/unit/services/vacayService.test.ts @@ -61,6 +61,11 @@ import { setShareHidden, getShareAvailableUsers, getSharedCalendars, + resolveYearWindow, + updateUserYearSettings, + getUserYearSettings, + getYearSettings, + currentPeriodYear, } from '../../../src/services/vacayService'; // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -760,6 +765,318 @@ describe('getStats', () => { expect(stats[0].used).toBe(1.5); expect(stats[0].remaining).toBe(28.5); }); + + it('VACAY-SVC-045b: comp/flex days cost nothing (#1074)', () => { + const { user, plan } = setupUserWithPlan(); + const yr = new Date().getFullYear(); + + toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation'); + toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp'); + + const stats = getStats(plan.id, yr); + + expect(stats[0].used).toBe(1); + expect(stats[0].remaining).toBe(29); + }); + + it('VACAY-SVC-045c: a half comp day also costs nothing (#1074)', () => { + const { user, plan } = setupUserWithPlan(); + const yr = new Date().getFullYear(); + + toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp'); + + const stats = getStats(plan.id, yr); + + expect(stats[0].used).toBe(0); + expect(stats[0].remaining).toBe(30); + }); + + it('VACAY-SVC-045d: comp_used reports comp days separately, summing fractions (#1074)', () => { + const { user, plan } = setupUserWithPlan(); + const yr = new Date().getFullYear(); + + toggleEntry(user.id, plan.id, `${yr}-09-17`, 1, 'comp'); + toggleEntry(user.id, plan.id, `${yr}-09-18`, 0.5, 'comp'); + toggleEntry(user.id, plan.id, `${yr}-09-19`, 1, 'vacation'); + + const stats = getStats(plan.id, yr); + + expect(stats[0].comp_used).toBe(1.5); + expect(stats[0].used).toBe(1); + }); + + it('VACAY-SVC-045e: converting a vacation day to comp refunds it to the entitlement (#1074)', () => { + const { user, plan } = setupUserWithPlan(); + const yr = new Date().getFullYear(); + + toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation'); + expect(getStats(plan.id, yr)[0].used).toBe(1); + + const result = toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'comp'); + + expect(result).toMatchObject({ action: 'updated', kind: 'comp' }); + expect(getStats(plan.id, yr)[0].used).toBe(0); + }); + + it('VACAY-SVC-045f: getStats reports the window it counted over (#737)', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); + + const stats = getStats(plan.id, 2026); + + expect(stats[0]).toMatchObject({ window_start: '2026-07-01', window_end: '2027-07-01' }); + }); +}); + +// ── Configurable vacation year (#737) ───────────────────────────────────────── + +describe('resolveYearWindow', () => { + it('VACAY-SVC-045g: defaults to the plain calendar year when nothing is configured', () => { + const { user } = setupUserWithPlan(); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + }); + + it('VACAY-SVC-045h: an explicit calendar setting resolves identically', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'calendar' }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + }); + + it('VACAY-SVC-045i: a fiscal year starts on the configured month and day', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-04-06', end: '2027-04-06' }); + }); + + it('VACAY-SVC-045j: an anniversary year follows the hire date month and day', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'anniversary', hire_date: '2019-09-16' }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-09-16', end: '2027-09-16' }); + }); + + it('VACAY-SVC-045k: an anniversary year without a hire date falls back to January 1', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'anniversary' }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + }); + + it('VACAY-SVC-045k1: an anniversary year ignores a month left behind by a previous fiscal setting', () => { + const { user } = setupUserWithPlan(); + // What the settings UI sends when you pick Fiscal/April and then click + // "Hire date" before typing one — it carries the whole settings object. + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 }); + updateUserYearSettings(user.id, { year_type: 'anniversary', year_start_month: 4, year_start_day: 1, hire_date: null }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + }); + + it('VACAY-SVC-045k2: a Feb 29 hire date resolves to Feb 28, a boundary every year has', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'anniversary', hire_date: '2024-02-29' }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-02-28', end: '2027-02-28' }); + }); + + it('VACAY-SVC-045k3: a fiscal day the month cannot have is clamped down to one it can', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 2, year_start_day: 31 }); + + expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-02-28', end: '2027-02-28' }); + }); + + it('VACAY-SVC-045l: consecutive periods meet exactly, so the carry-over chain stays intact', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + + expect(resolveYearWindow(user.id, 2025).end).toBe(resolveYearWindow(user.id, 2026).start); + }); +}); + +describe('updateUserYearSettings', () => { + it('VACAY-SVC-045m: upserts, so a second call replaces the first', () => { + const { user } = setupUserWithPlan(); + + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); + const saved = updateUserYearSettings(user.id, { year_type: 'calendar' }); + + expect(saved.year_type).toBe('calendar'); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM vacay_user_settings WHERE user_id = ?').get(user.id)).toEqual({ n: 1 }); + }); + + it('VACAY-SVC-045n: clamps an out-of-range month and day instead of storing them', () => { + const { user } = setupUserWithPlan(); + + const saved = updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 99, year_start_day: 0 }); + + expect(saved.year_start_month).toBe(12); + expect(saved.year_start_day).toBe(1); + }); + + it('VACAY-SVC-045o: drops a malformed hire date rather than persisting it', () => { + const { user } = setupUserWithPlan(); + + const saved = updateUserYearSettings(user.id, { year_type: 'anniversary', hire_date: 'not-a-date' }); + + expect(saved.hire_date).toBeNull(); + }); + + it('VACAY-SVC-045p: an unknown year type falls back to calendar', () => { + const { user } = setupUserWithPlan(); + + expect(updateUserYearSettings(user.id, { year_type: 'quarterly' }).year_type).toBe('calendar'); + }); +}); + +describe('getYearSettings', () => { + it('VACAY-SVC-045q: fills in the calendar defaults for a user who never configured anything', () => { + const { user } = setupUserWithPlan(); + + expect(getUserYearSettings(user.id)).toBeUndefined(); + expect(getYearSettings(user.id)).toEqual({ + user_id: user.id, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null, + }); + }); +}); + +describe('currentPeriodYear', () => { + it('VACAY-SVC-045r: a calendar user is always in the period named after today’s year', () => { + const { user } = setupUserWithPlan(); + + expect(currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2026); + }); + + it('VACAY-SVC-045s: before a fiscal year starts, today still belongs to the previous period', () => { + const { user } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + + expect(currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2025); + expect(currentPeriodYear(user.id, new Date('2026-08-15T12:00:00'))).toBe(2026); + }); +}); + +describe('usage over a shifted window (#737)', () => { + it('VACAY-SVC-045t: a day in the next calendar year still counts toward the fiscal period', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + + toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside, first half + toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, second half + + expect(getStats(plan.id, 2026)[0].used).toBe(2); + }); + + it('VACAY-SVC-045u: days outside the window belong to the neighbouring periods, not this one', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + + toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // last day of the previous period + toggleEntry(user.id, plan.id, '2027-07-01', 1, 'vacation'); // first day of the next period + + expect(getStats(plan.id, 2026)[0].used).toBe(0); + expect(getStats(plan.id, 2025)[0].used).toBe(1); + expect(getStats(plan.id, 2027)[0].used).toBe(1); + }); + + // Periods well past the year seeded with the plan, so addYear really inserts. + it('VACAY-SVC-045v: carry-over is computed over the previous period, not the previous calendar year', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); + testDb.prepare(` + INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) + VALUES (?, ?, 2030, 10, 0) + `).run(user.id, plan.id); + + // Two days inside the 2030 period (Jul 2030 – Jun 2031), one of them in 2031. + toggleEntry(user.id, plan.id, '2030-09-01', 1, 'vacation'); + toggleEntry(user.id, plan.id, '2031-02-01', 1, 'vacation'); + + addYear(plan.id, 2031, undefined); + + const row = testDb + .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = 2031') + .get(user.id, plan.id) as { carried_over: number }; + expect(row.carried_over).toBe(8); + }); + + it('VACAY-SVC-045w: comp days are excluded from the carry-over of a shifted period too', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); + testDb.prepare(` + INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) + VALUES (?, ?, 2030, 10, 0) + `).run(user.id, plan.id); + + toggleEntry(user.id, plan.id, '2031-02-01', 1, 'comp'); + + addYear(plan.id, 2031, undefined); + + const row = testDb + .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = 2031') + .get(user.id, plan.id) as { carried_over: number }; + expect(row.carried_over).toBe(10); + }); + + it('VACAY-SVC-045x: deleteYear clears the entries of the period, spanning both calendar years', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + addYear(plan.id, 2026, undefined); + + toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside + toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, next calendar year + toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period, must survive + + deleteYear(plan.id, 2026, undefined); + + const left = testDb + .prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date') + .all(plan.id) as { date: string }[]; + expect(left.map(r => r.date)).toEqual(['2026-06-30']); + }); +}); + +describe('getEntries over a window (#737)', () => { + it('VACAY-SVC-045y: returns both calendar halves of a shifted period for the viewer', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + + toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); + toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); + toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period + + const result = getEntries(plan.id, '2026', user.id); + + expect((result.entries as { date: string }[]).map(e => e.date).sort()).toEqual(['2026-08-10', '2027-02-10']); + }); + + it('VACAY-SVC-045z: without a viewer it stays on the plain calendar year (MCP reads)', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + + toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); + toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); + + const result = getEntries(plan.id, '2026'); + + expect((result.entries as { date: string }[]).map(e => e.date)).toEqual(['2026-08-10']); + }); + + it('VACAY-SVC-045aa: a start day past the 1st still loads the whole first month, since the grid renders it', () => { + const { user, plan } = setupUserWithPlan(); + updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 }); + + toggleEntry(user.id, plan.id, '2026-04-02', 1, 'vacation'); // rendered, but counted in the previous period + + expect((getEntries(plan.id, '2026', user.id).entries as unknown[])).toHaveLength(1); + expect(getStats(plan.id, 2026)[0].used).toBe(0); + expect(getStats(plan.id, 2025)[0].used).toBe(1); + }); }); // ── applyHolidayCalendars ───────────────────────────────────────────────────── @@ -1010,8 +1327,8 @@ describe('getSharedCalendars', () => { expect(calendars[0].owner_name).toBe(owner.username); expect(calendars[0].hidden).toBe(false); expect(calendars[0].entries).toEqual([ - { date: '2025-06-10', fraction: 1 }, - { date: '2025-06-11', fraction: 0.5 }, + { date: '2025-06-10', fraction: 1, kind: 'vacation' }, + { date: '2025-06-11', fraction: 0.5, kind: 'vacation' }, ]); }); @@ -1065,6 +1382,6 @@ describe('getSharedCalendars', () => { const calendars = getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); - expect(calendars[0].entries).toEqual([{ date: '2025-03-03', fraction: 1 }]); + expect(calendars[0].entries).toEqual([{ date: '2025-03-03', fraction: 1, kind: 'vacation' }]); }); });