diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index a23119f7c..8a01b5206 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -18,5 +18,5 @@ "src/nest/plugins/runtime/plugin-sdk.ts": 1093, "src/nest/reservations/reservations.service.ts": 1239, "src/nest/trips/trips.service.ts": 1009, - "src/nest/vacay/vacay.service.ts": 1433 + "src/nest/vacay/vacay.service.ts": 1420 } diff --git a/server/src/db/repositories/VacayEntries.repository.ts b/server/src/db/repositories/VacayEntries.repository.ts index ccc9574d8..acfb60ed6 100644 --- a/server/src/db/repositories/VacayEntries.repository.ts +++ b/server/src/db/repositories/VacayEntries.repository.ts @@ -1,4 +1,3 @@ -import { dateAdd } from '../dialect/sql-functions'; import type { VacayEntries } from '../entities/VacayEntries.entity'; import type { DB } from '../kysely/db'; import { TrekRepository } from './_shared/trek-repository'; @@ -18,13 +17,6 @@ export interface VacayEntryWithPersonRow { person_color: string; } -/** SQLite's `date(, '+N days')` — the same calendar-day arithmetic `dateAdd` spells in SQL (§13), computed in JS for {@link VacayEntriesRepository.shiftForOwnerWindow}'s collision pre-check. Verified against SQLite's own `date()` modifier: both walk the proleptic Gregorian calendar by whole days, so they agree on every input (the same reasoning R9's VC4 date-diff verification gives). */ -function shiftIsoDate(isoDate: string, days: number): string { - const d = new Date(`${isoDate}T00:00:00Z`); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); -} - export class VacayEntriesRepository extends TrekRepository { /** * VC1 — `SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction @@ -79,40 +71,28 @@ export class VacayEntriesRepository extends TrekRepository { } /** - * VC5 (`shiftOwnerEntriesForTripWindow`) — `UPDATE OR IGNORE vacay_entries - * SET date = date(date, ? || ' days') WHERE plan_id = ? AND user_id = ? AND - * date BETWEEN ? AND ?`. SQLite's per-statement `OR IGNORE` - * conflict-resolution clause applies to UPDATE as well as INSERT, but - * neither Kysely's `UpdateQueryBuilder` (only `.modifyEnd()`, which appends - * AFTER the whole statement, not after the `UPDATE` keyword) nor MikroORM's - * `nativeUpdate` exposes it — a genuine builder gap, not a helper this - * plan's R9 anticipated (R9's own VC5 note only covers the date - * EXPRESSION, via the existing `dateAdd`, not this conflict-resolution - * clause). Reproduced as a read-then-conditionally-write loop instead: - * read every candidate row, and for each one whose shifted date would - * collide with an existing `(user, plan, date)` row, skip it — `OR - * IGNORE`'s own per-row behavior, exactly — otherwise write it with - * `dateAdd` (still the SQL-computed value, per R9). Low-volume, - * request-scoped (a trip's own leave entries on a date-window move, never - * a hot path), so the per-row round trip is not a concern. + * VC5 (`shiftOwnerEntriesForTripWindow`): `SELECT id, date, fraction FROM + * vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN ? AND ? + * ORDER BY date`: the leave days a trip window move carries along, and the + * ones already standing on its targets. The service decides each row's fate + * and writes it with {@link moveEntry} or {@link deleteById} (#2604). */ - async shiftForOwnerWindow(planId: number, userId: number, start: string, end: string, offset: number): Promise { - const platform = this.getEntityManager().getPlatform(); - // M1 (task-7-review.md): legacy's single `UPDATE OR IGNORE` walks rows in - // rowid order; without an explicit orderBy here, SQLite returns rows in - // (user, plan, date) index order instead, so the per-row collision check - // above skips different rows than legacy did on consecutive days inserted - // out of id order. + async listForOwnerWindow( + planId: number, + userId: number, + start: string, + end: string, + ): Promise<{ id: number; date: string; fraction: number }[]> { const rows = await this.find( { plan: planId, user: userId, date: { $gte: start, $lte: end } }, - { fields: ['id', 'date'], orderBy: { id: 'asc' } }, + { fields: ['id', 'date', 'fraction'], orderBy: { date: 'asc' } }, ); - for (const row of rows) { - const shiftedDate = shiftIsoDate(row.date, offset); - const collision = await this.findOne({ plan: planId, user: userId, date: shiftedDate }, { fields: ['id'] }); - if (collision) continue; - await this.nativeUpdate({ id: row.id }, { date: dateAdd(platform, 'date', offset) }); - } + return rows.map((row) => ({ id: row.id, date: row.date, fraction: row.fraction })); + } + + /** VC5: `UPDATE vacay_entries SET date = ?, fraction = ? WHERE id = ?`, one moved day of `shiftOwnerEntriesForTripWindow`. */ + async moveEntry(id: number, date: string, fraction: number): Promise { + await this.nativeUpdate({ id }, { date, fraction }); } /** VC24/VC30/VC121 — `DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear, and `makeRoomForCompanyHoliday`'s whole-holiday clear, reached from `updatePlan`'s company-holiday enable and `toggleCompanyHoliday`). */ diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 6f5299a3b..1cc21c896 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -522,6 +522,7 @@ export class TripsService { // The row, the leave entries that follow its dates and the rebuilt day grid are // one write: a failure halfway used to leave a trip whose days missed its dates. let removedDays: DayGridRemoval[] = []; + let vacayId: number | undefined; // the vacay plan whose leave days moved along (#2604) await this.uow.transactional(async () => { // Before the row: the rebase reads the outgoing currency off it (#1543). if (rebase) await this.budget.applyCurrencyRebase(tripId, rebase); @@ -542,7 +543,7 @@ export class TripsService { }); if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) - await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); + vacayId = await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); if (regenerate) { // Accommodations have no absolute date columns, so their pre-change dates must be @@ -565,6 +566,7 @@ export class TripsService { } } }); + if (vacayId !== undefined) await this.vacay.notifyPlanUsers(vacayId, undefined); const changes: Record = {}; if (title && title !== trip.title) changes.title = title; diff --git a/server/src/nest/vacay/vacay.helpers.ts b/server/src/nest/vacay/vacay.helpers.ts new file mode 100644 index 000000000..7396ba80d --- /dev/null +++ b/server/src/nest/vacay/vacay.helpers.ts @@ -0,0 +1,71 @@ +/** + * Pure half of the vacay domain (no DB access), moved out of vacay.service.ts + * so the service keeps to its size budget. + */ + +/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */ +export function normalizeFraction(value: unknown): number { + return Number(value) === 0.5 ? 0.5 : 1; +} + +/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */ +export function normalizeKind(value: unknown): 'vacation' | 'comp' { + return value === 'comp' ? 'comp' : 'vacation'; +} + +/** + * Same semantics as the client's isWeekend guard (holidays.ts): UTC weekday, + * weekend_days falls back to Sat/Sun when the column is NULL or empty. + */ +export function isBlockedWeekend( + plan: { block_weekends: number | null; weekend_days: string | null }, + date: string, +): boolean { + if (!plan.block_weekends) return false; + const days = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]; + return days.includes(new Date(date + 'T00:00:00Z').getUTCDay()); +} + +export const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */ +export function daysAlwaysInMonth(month: number): number { + if (month === 2) return 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** Coerce an arbitrary input to a supported leave-year type (#737). */ +export function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' { + return value === 'fiscal' || value === 'anniversary' ? value : 'calendar'; +} + +/** + * The calendar year a window's last day falls in. `end` is exclusive, so a window + * ending on Jan 1 (the calendar default) stops inside the previous year. + */ +export function windowEndYear(end: string): number { + const y = Number.parseInt(end.slice(0, 4), 10); + return end.endsWith('-01-01') ? y - 1 : y; +} + +/** + * Parse a date or datetime string the way SQLite's `julianday()` does — the + * numeric components taken at face value as UTC, never the host's local + * timezone. `Date.parse` only does this for a bare `YYYY-MM-DD`; a datetime + * string with no zone suffix (`2025-06-12T23:30`) is local time per the ES + * spec, which drifted from `julianday`'s always-UTC reading by whatever the + * server's offset is (M2, task-7-review.md). Unparseable input returns `NaN`. + */ +export function parseAsUtcMillis(value: string): number { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value); + if (!m) return Number.NaN; + const [, y, mo, d, h, mi, s] = m; + return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0), Number(s ?? 0)); +} + +/** A `YYYY-MM-DD` date moved by whole calendar days, in UTC. */ +export function shiftIsoDate(isoDate: string, days: number): string { + const d = new Date(`${isoDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 89b13e12c..dc0da9c1c 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -28,6 +28,10 @@ import { VacayUserSettings as VacayUserSettingsEntity } from '../../db/entities/ import type { VacayUserSettingsRepository } from '../../db/repositories/VacayUserSettings.repository'; import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.entity'; import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository'; +import { + daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis, + shiftIsoDate, windowEndYear, +} from './vacay.helpers'; // --------------------------------------------------------------------------- // Types @@ -141,67 +145,6 @@ const COLORS = [ '#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488', ]; -// --------------------------------------------------------------------------- -// Pure helpers (no DB access) -// --------------------------------------------------------------------------- - -/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */ -function normalizeFraction(value: unknown): number { - return Number(value) === 0.5 ? 0.5 : 1; -} - -/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */ -function normalizeKind(value: unknown): 'vacation' | 'comp' { - return value === 'comp' ? 'comp' : 'vacation'; -} - -/** - * Same semantics as the client's isWeekend guard (holidays.ts): UTC weekday, - * weekend_days falls back to Sat/Sun when the column is NULL or empty. - */ -function isBlockedWeekend(plan: Pick, date: string): boolean { - if (!plan.block_weekends) return false; - const days = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]; - return days.includes(new Date(date + 'T00:00:00Z').getUTCDay()); -} - -const pad2 = (n: number) => String(n).padStart(2, '0'); - -/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */ -function daysAlwaysInMonth(month: number): number { - if (month === 2) return 28; - return [4, 6, 9, 11].includes(month) ? 30 : 31; -} - -/** Coerce an arbitrary input to a supported leave-year type (#737). */ -function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' { - return value === 'fiscal' || value === 'anniversary' ? value : 'calendar'; -} - -/** - * The calendar year a window's last day falls in. `end` is exclusive, so a window - * ending on Jan 1 (the calendar default) stops inside the previous year. - */ -function windowEndYear(end: string): number { - const y = Number.parseInt(end.slice(0, 4), 10); - return end.endsWith('-01-01') ? y - 1 : y; -} - -/** - * Parse a date or datetime string the way SQLite's `julianday()` does — the - * numeric components taken at face value as UTC, never the host's local - * timezone. `Date.parse` only does this for a bare `YYYY-MM-DD`; a datetime - * string with no zone suffix (`2025-06-12T23:30`) is local time per the ES - * spec, which drifted from `julianday`'s always-UTC reading by whatever the - * server's offset is (M2, task-7-review.md). Unparseable input returns `NaN`. - */ -function parseAsUtcMillis(value: string): number { - const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value); - if (!m) return Number.NaN; - const [, y, mo, d, h, mi, s] = m; - return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0), Number(s ?? 0)); -} - /** * Vacay domain service — owns the vacay SQL, now through the ten vacay * repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read, @@ -405,12 +348,22 @@ export class VacayService { return (await this.getActivePlan(userId)).id; } + /** + * A trip's new start carries its owner's leave days along by the same offset + * (#983), in whatever plan the owner is active in, a fused one included. Each + * day either moves or is dropped: a target on a blocked weekend or a whole + * company holiday is no leave day, and a target already holding leave that + * stays put is leave already. A half company holiday leaves half a day, as in + * toggleEntry. Public holidays come from an outside API and are not checked + * inside the trip's transaction. Returns the plan whose entries changed, so + * the caller can notify its users once the transaction is committed (#2604). + */ async shiftOwnerEntriesForTripWindow( ownerId: number, oldStart: string, oldEnd: string, newStart: string - ): Promise { + ): Promise { // VC4 (R9's verified restructured shape): `CAST(julianday(?) - julianday(?) // AS INTEGER)` on two BOUND VALUES is plain JS date-diff arithmetic — // verified against the SQL on 5 date pairs incl. a leap-year boundary and a @@ -422,11 +375,45 @@ export class VacayService { // same as legacy, instead of writing a NaN date and 500ing after the trip // row is already committed. const offset = Math.trunc((parseAsUtcMillis(newStart) - parseAsUtcMillis(oldStart)) / 86400000); - if (!Number.isFinite(offset) || offset === 0) return; + if (!Number.isFinite(offset) || offset === 0) return undefined; - const plan = await this.getOwnPlan(ownerId); + // A trip edit never creates a plan: without one there is no leave to move. + const plan = await this.peekActivePlan(ownerId); + if (!plan) return undefined; - await this.entries.shiftForOwnerWindow(plan.id, ownerId, oldStart, oldEnd, offset); + return await this.uow.transactional(async () => { + const moving = await this.entries.listForOwnerWindow(plan.id, ownerId, oldStart, oldEnd); + if (moving.length === 0) return undefined; + const movingIds = new Set(moving.map(row => row.id)); + const firstTarget = shiftIsoDate(moving[0].date, offset); + const lastTarget = shiftIsoDate(moving[moving.length - 1].date, offset); + const standing = new Set( + (await this.entries.listForOwnerWindow(plan.id, ownerId, firstTarget, lastTarget)) + .filter(row => !movingIds.has(row.id)) + .map(row => row.date), + ); + const companyHolidays = plan.company_holidays_enabled + ? await this.companyHolidays.listDatesForRange(plan.id, firstTarget, shiftIsoDate(lastTarget, 1)) + : []; + const companyFractions = new Map(companyHolidays.map(h => [h.date, h.fraction] as const)); + + const moves: { id: number; date: string; fraction: number }[] = []; + for (const row of moving) { + const date = shiftIsoDate(row.date, offset); + const company = companyFractions.get(date); + if (standing.has(date) || isBlockedWeekend(plan, date) || (company !== undefined && company >= 1)) { + await this.entries.deleteById(row.id); + } else { + moves.push({ id: row.id, date, fraction: company !== undefined && row.fraction > 0.5 ? 0.5 : row.fraction }); + } + } + // Rows move in the direction of travel, so each target is already free of + // the moving row that held it when the next one arrives: (user, plan, date) + // is unique. + if (offset > 0) moves.reverse(); + for (const move of moves) await this.entries.moveEntry(move.id, move.date, move.fraction); + return plan.id; + }); } async getPlanUsers(planId: number): Promise { diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index e16999abc..4ee4c6992 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -28,6 +28,8 @@ import { Journeys } from '../../src/db/entities/Journeys.entity'; import { Settings } from '../../src/db/entities/Settings.entity'; import { TripMembers } from '../../src/db/entities/TripMembers.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; +import { VacayEntries } from '../../src/db/entities/VacayEntries.entity'; +import { VacayPlans } from '../../src/db/entities/VacayPlans.entity'; // TripsService itself is real since the trip fold — no tripService mock; the // real migrated trips/trip_members/days tables serve its SQL. // bundle()'s days + accommodations now run DaysService's real SQL (DI-injected, @@ -40,10 +42,11 @@ import { BudgetService } from '../../src/nest/budget/budget.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { TripsModule } from '../../src/nest/trips/trips.module'; import { makeDayAssignment } from '../helpers/factories/itinerary'; import { makePlace } from '../helpers/factories/places'; -import { countRows, deleteRows, findRow, findRows, insertRow } from '../helpers/factories/rows'; +import { countRows, deleteRows, findRow, findRows, insertRow, insertRows } from '../helpers/factories/rows'; import { setUserSetting } from '../helpers/factories/settings'; import { makeDay } from '../helpers/factories/trips'; import { makeUser } from '../helpers/factories/users'; @@ -235,6 +238,41 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(await dayIds(week.body.trip.id)).toEqual(kept); }); + it('200 update with a later start moves the leave days of the owner along, off the blocked weekend, and tells the plan (#2604)', async () => { + // 2030-11-25 is a Monday; the plan blocks Saturday and Sunday by default. + const trip = await request(server) + .post('/api/trips') + .set('Cookie', sessionCookie(1)) + .send({ title: 'Leave', start_date: '2030-11-25', end_date: '2030-11-30' }); + const shift = (start_date: string, end_date: string) => + request(server) + .put(`/api/trips/${trip.body.trip.id}`) + .set('Cookie', sessionCookie(1)) + .send({ start_date, end_date }); + + // No vacay plan yet: moving the trip creates none. + expect((await shift('2030-11-26', '2030-12-01')).status).toBe(200); + expect(await countRows(orm, VacayPlans, { owner: 1 })).toBe(0); + expect((await shift('2030-11-25', '2030-11-30')).status).toBe(200); + + const planId = await insertRow(orm, VacayPlans, { owner: 1 }); + const week = ['2030-11-25', '2030-11-26', '2030-11-27', '2030-11-28', '2030-11-29']; + await insertRows( + orm, + VacayEntries, + week.map((date) => ({ plan: planId, user: 1, date, note: '' })), + ); + const broadcast = vi.spyOn(app.get(RealtimeService), 'broadcastToUser'); + + expect((await shift('2030-11-27', '2030-12-02')).status).toBe(200); + + const dates = (await findRows(orm, VacayEntries, { plan: planId }, { date: 'asc' })).map((e) => e.date); + expect(dates).toEqual(['2030-11-27', '2030-11-28', '2030-11-29']); + expect(broadcast).toHaveBeenCalledWith(1, { type: 'vacay:update' }, undefined); + broadcast.mockRestore(); + await deleteRows(orm, VacayPlans, { id: planId }); + }); + it('404 on a missing trip', async () => { const res = await request(server).get('/api/trips/77').set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); diff --git a/server/tests/unit/nest/vacay-trip-window.test.ts b/server/tests/unit/nest/vacay-trip-window.test.ts new file mode 100644 index 000000000..3155999cb --- /dev/null +++ b/server/tests/unit/nest/vacay-trip-window.test.ts @@ -0,0 +1,219 @@ +/** + * VacayService.shiftOwnerEntriesForTripWindow: a trip's new start carries its + * owner's leave days along (#983). Moved out of vacay.service.test.ts with the + * #2604 cases (a block moves whole, blocked and taken targets drop the day, + * fused plans, no lazy plan). + */ +import { VacayCompanyHolidays } from '../../../src/db/entities/VacayCompanyHolidays.entity'; +import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity'; +import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.entity'; +import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity'; +import { VacayModule } from '../../../src/nest/vacay/vacay.module'; +import { VacayService } from '../../../src/nest/vacay/vacay.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { countRows, deleteRows, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows'; +import { makeUser } from '../../helpers/factories/users'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let svc: VacayService; +const orm = () => t.get(MikroORM); + +beforeAll(async () => { + t = await createTestModule({ db, imports: [VacayModule] }); + svc = t.get(VacayService); +}); + +afterAll(async () => { + await t.close(); + db.close(); +}); + +/** A user with their own plan, weekends blocked as a new plan has them. */ +async function setupUserWithPlan() { + const { user } = await makeUser(orm()); + const plan = await svc.getOwnPlan(user.id); + return { user, plan }; +} + +/** The user's entry dates in the plan, in date order. */ +async function datesOf(planId: number, userId: number): Promise { + return (await findRows(orm(), VacayEntries, { plan: planId, user: userId }, { date: 'asc' })).map((r) => r.date); +} + +/** Replace the plan's entries with one per date for the user, inserted in the order given. */ +async function seedEntries(planId: number, userId: number, dates: string[]): Promise { + await deleteRows(orm(), VacayEntries, { plan: planId }); + await insertRows( + orm(), + VacayEntries, + dates.map((date) => ({ plan: planId, user: userId, date, note: '' })), + ); +} + +describe('shiftOwnerEntriesForTripWindow', () => { + it('VACAY-SVC-030b (VC4 parity): shifts an entry by the exact calendar-day offset between the old and new trip start, across several date-pair shapes incl. a leap-year boundary', async () => { + // R9's verified restructured shape (task-0-report.md): plain JS date-diff + // arithmetic in place of the legacy CAST(julianday(?) - julianday(?) AS + // INTEGER), verified there against the SQL on 5 date pairs incl. a + // leap-year boundary and a negative offset. Every target is a weekday. + const { user, plan } = await setupUserWithPlan(); + const shiftOne = async (oldStart: string, oldEnd: string, newStart: string, entryDate: string) => { + await seedEntries(plan.id, user.id, [entryDate]); + await svc.shiftOwnerEntriesForTripWindow(user.id, oldStart, oldEnd, newStart); + return datesOf(plan.id, user.id); + }; + + expect(await shiftOne('2026-03-01', '2026-03-10', '2026-03-06', '2026-03-05')).toEqual(['2026-03-10']); // +5 days + expect(await shiftOne('2024-02-25', '2024-03-05', '2024-02-28', '2024-03-01')).toEqual(['2024-03-04']); // leap year, +3 + expect(await shiftOne('2026-06-10', '2026-06-20', '2026-06-03', '2026-06-15')).toEqual(['2026-06-08']); // -7 days + }); + + it('VACAY-SVC-030c: a zero offset is a no-op (no write at all)', async () => { + const { user, plan } = await setupUserWithPlan(); + await seedEntries(plan.id, user.id, ['2026-05-05']); + + await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', '2026-05-01'); + + expect(await datesOf(plan.id, user.id)).toEqual(['2026-05-05']); + }); + + it("VACAY-SVC-030d (M2 parity): an unparseable start (garbage) resolves as a no-op, same as legacy's NULL-julianday offset of 0, instead of writing a NaN date", async () => { + const { user, plan } = await setupUserWithPlan(); + await seedEntries(plan.id, user.id, ['2026-05-05']); + + await expect( + svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', 'soon'), + ).resolves.toBeUndefined(); + + expect(await datesOf(plan.id, user.id)).toEqual(['2026-05-05']); + }); + + it("VACAY-SVC-030e (M2 parity): a datetime start with no zone suffix is parsed as UTC and truncated, matching base's CAST(julianday(...) AS INTEGER) rather than a local-time round", async () => { + // base: CAST(julianday('2025-06-12T23:30') - julianday('2025-06-10') AS INTEGER) = 2 + // (2 days 23.5 hours, truncated toward zero); a local-time Date.parse + + // Math.round of the same pair computed 3 instead (task-7-review.md M2). + const { user, plan } = await setupUserWithPlan(); + await seedEntries(plan.id, user.id, ['2025-06-15']); + + await svc.shiftOwnerEntriesForTripWindow(user.id, '2025-06-10', '2025-06-20', '2025-06-12T23:30'); + + expect(await datesOf(plan.id, user.id)).toEqual(['2025-06-17']); + }); + + it('VACAY-SVC-030f (#2604): a block of consecutive days moves whole, in both directions and whatever order the rows were inserted in', async () => { + // The old per-row `UPDATE OR IGNORE` checked each target against rows that + // were about to move themselves, so a block shifted by less than its own + // length left its first days behind. 2026-07-14 is a Tuesday. + const { user, plan } = await setupUserWithPlan(); + const shiftBlock = async (dates: string[], newStart: string) => { + await seedEntries(plan.id, user.id, dates); + await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-01', '2026-07-20', newStart); + return datesOf(plan.id, user.id); + }; + const ascending = ['2026-07-14', '2026-07-15', '2026-07-16']; + const descending = [...ascending].reverse(); + + expect(await shiftBlock(descending, '2026-07-02')).toEqual(['2026-07-15', '2026-07-16', '2026-07-17']); + expect(await shiftBlock(ascending, '2026-07-02')).toEqual(['2026-07-15', '2026-07-16', '2026-07-17']); + expect(await shiftBlock(descending, '2026-06-30')).toEqual(['2026-07-13', '2026-07-14', '2026-07-15']); + expect(await shiftBlock(ascending, '2026-06-30')).toEqual(['2026-07-13', '2026-07-14', '2026-07-15']); + }); + + it('VACAY-SVC-030g (#2604): a Monday to Friday block moved by two days keeps its length without weekend blocking and drops the days that land on a blocked weekend', async () => { + // 2030-11-25 is a Monday. + const { user, plan } = await setupUserWithPlan(); + const week = ['2030-11-25', '2030-11-26', '2030-11-27', '2030-11-28', '2030-11-29']; + const shiftWeek = async (newStart: string) => { + await seedEntries(plan.id, user.id, week); + await svc.shiftOwnerEntriesForTripWindow(user.id, '2030-11-25', '2030-11-30', newStart); + return datesOf(plan.id, user.id); + }; + + expect(await shiftWeek('2030-11-27')).toEqual(['2030-11-27', '2030-11-28', '2030-11-29']); + expect(await shiftWeek('2030-11-23')).toEqual(['2030-11-25', '2030-11-26', '2030-11-27']); + + await updateRows(orm(), VacayPlans, { id: plan.id }, { block_weekends: 0 }); + expect(await shiftWeek('2030-11-27')).toEqual([ + '2030-11-27', + '2030-11-28', + '2030-11-29', + '2030-11-30', + '2030-12-01', + ]); + expect(await shiftWeek('2030-11-23')).toEqual([ + '2030-11-23', + '2030-11-24', + '2030-11-25', + '2030-11-26', + '2030-11-27', + ]); + }); + + it('VACAY-SVC-030h (#2604): a day whose target already holds leave that is not moving is dropped, the standing entry stays as it was', async () => { + const { user, plan } = await setupUserWithPlan(); + await insertRows(orm(), VacayEntries, [ + { plan: plan.id, user: user.id, date: '2026-07-14', note: '' }, + { plan: plan.id, user: user.id, date: '2026-07-15', note: '' }, + { plan: plan.id, user: user.id, date: '2026-07-17', note: 'standing', fraction: 0.5, kind: 'comp' }, + ]); + + await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-14', '2026-07-16', '2026-07-16'); + + const rows = await findRows(orm(), VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' }); + expect(rows.map(({ date, note, fraction, kind }) => ({ date, note, fraction, kind }))).toEqual([ + { date: '2026-07-16', note: '', fraction: 1, kind: 'vacation' }, + { date: '2026-07-17', note: 'standing', fraction: 0.5, kind: 'comp' }, + ]); + }); + + it('VACAY-SVC-030i (#2604): a target on a whole company holiday drops the day, one on a half company holiday keeps half of it', async () => { + const { user, plan } = await setupUserWithPlan(); + await updateRows(orm(), VacayPlans, { id: plan.id }, { company_holidays_enabled: 1 }); + await insertRows(orm(), VacayCompanyHolidays, [ + { plan: plan.id, date: '2026-07-15', note: '', fraction: 1 }, + { plan: plan.id, date: '2026-07-16', note: '', fraction: 0.5 }, + ]); + await seedEntries(plan.id, user.id, ['2026-07-13', '2026-07-14']); + + await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-13', '2026-07-14', '2026-07-15'); + + const rows = await findRows(orm(), VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' }); + expect(rows.map(({ date, fraction }) => ({ date, fraction }))).toEqual([{ date: '2026-07-16', fraction: 0.5 }]); + }); + + it("VACAY-SVC-030j (#2604): a member of a fused plan has their own days moved in the shared plan, nobody else's, and gets no plan of their own", async () => { + const { user: owner, plan } = await setupUserWithPlan(); + const { user: member } = await makeUser(orm()); + await insertRow(orm(), VacayPlanMembers, { plan: plan.id, user: member.id, status: 'accepted' }); + await insertRows( + orm(), + VacayEntries, + [member.id, owner.id].flatMap((userId) => [ + { plan: plan.id, user: userId, date: '2026-07-14', note: '' }, + { plan: plan.id, user: userId, date: '2026-07-15', note: '' }, + ]), + ); + + const changed = await svc.shiftOwnerEntriesForTripWindow(member.id, '2026-07-14', '2026-07-15', '2026-07-21'); + + expect(changed).toBe(plan.id); + expect(await datesOf(plan.id, member.id)).toEqual(['2026-07-21', '2026-07-22']); + expect(await datesOf(plan.id, owner.id)).toEqual(['2026-07-14', '2026-07-15']); + expect(await countRows(orm(), VacayPlans, { owner: member.id })).toBe(0); + }); + + it('VACAY-SVC-030k (#2604): a trip owner without a vacay plan gets none from moving the trip', async () => { + const { user } = await makeUser(orm()); + + await expect( + svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-14', '2026-07-15', '2026-07-21'), + ).resolves.toBeUndefined(); + + expect(await countRows(orm(), VacayPlans, { owner: user.id })).toBe(0); + }); +}); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 7103940ec..26aa941ae 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -549,97 +549,7 @@ describe('deleteYear', () => { }); }); -// ── shiftOwnerEntriesForTripWindow (VC4 restructured date-diff shape) ───────── - -describe('shiftOwnerEntriesForTripWindow', () => { - it('VACAY-SVC-030b (VC4 parity): shifts an entry by the exact calendar-day offset between the old and new trip start, across several date-pair shapes incl. a leap-year boundary', async () => { - // R9's verified restructured shape (task-0-report.md): plain JS - // Math.round((Date.parse(newStart) - Date.parse(oldStart)) / 86400000) - // in place of the legacy CAST(julianday(?) - julianday(?) AS INTEGER) — - // verified there against the SQL on 5 date pairs incl. a leap-year - // boundary and a negative offset, all matched exactly. This proves the - // SAME arithmetic end-to-end through the converted service method. - const { user, plan } = await setupUserWithPlan(); - const cases: [oldStart: string, oldEnd: string, newStart: string, entryDate: string, expectedShifted: string][] = [ - ['2026-03-01', '2026-03-10', '2026-03-06', '2026-03-05', '2026-03-10'], // +5 days - ['2024-02-25', '2024-03-05', '2024-02-28', '2024-03-01', '2024-03-04'], // leap-year boundary, +3 days - ['2026-06-10', '2026-06-20', '2026-06-03', '2026-06-15', '2026-06-08'], // -7 days - ]; - for (const [oldStart, oldEnd, newStart, entryDate, expectedShifted] of cases) { - await deleteRows(await orm(), VacayEntries, { plan: plan.id }); - await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: entryDate, note: '' }); - - await svc.shiftOwnerEntriesForTripWindow(user.id, oldStart, oldEnd, newStart); - - const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; - expect(row.date).toBe(expectedShifted); - } - }); - - it('VACAY-SVC-030c: a zero offset is a no-op (no write at all)', async () => { - const { user, plan } = await setupUserWithPlan(); - await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2026-05-05', note: '' }); - - await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', '2026-05-01'); - - const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; - expect(row.date).toBe('2026-05-05'); - }); - - it('VACAY-SVC-030d (M2 parity): an unparseable start (garbage) resolves as a no-op, same as legacy\'s NULL-julianday offset of 0, instead of writing a NaN date', async () => { - const { user, plan } = await setupUserWithPlan(); - await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2026-05-05', note: '' }); - - await expect(svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', 'soon')).resolves.toBeUndefined(); - - const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; - expect(row.date).toBe('2026-05-05'); - }); - - it('VACAY-SVC-030e (M2 parity): a datetime start with no zone suffix is parsed as UTC and truncated, matching base\'s CAST(julianday(...) AS INTEGER) rather than a local-time round', async () => { - // base: CAST(julianday('2025-06-12T23:30') - julianday('2025-06-10') AS INTEGER) = 2 - // (2 days 23.5 hours, truncated toward zero) — a local-time Date.parse + - // Math.round of the same pair computed 3 instead (task-7-review.md M2). - const { user, plan } = await setupUserWithPlan(); - await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2025-06-15', note: '' }); - - await svc.shiftOwnerEntriesForTripWindow(user.id, '2025-06-10', '2025-06-20', '2025-06-12T23:30'); - - const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; - expect(row.date).toBe('2025-06-17'); - }); - - it('VACAY-SVC-030f (M1 parity): consecutive-day entries inserted in descending-id order shift to the rowid-ordered (legacy) outcome, not the date-index-ordered one', async () => { - // Legacy's single `UPDATE OR IGNORE` walks rows in rowid order. - // VacayEntriesRepository.shiftForOwnerWindow's `find` had no `orderBy`, so - // SQLite returned candidates in (user, plan, date) index order instead — - // a different per-row collision check skips different rows - // (task-7-review.md M1). Inserting the latest date first makes the id - // order run opposite the date order, so the two orderings disagree. - const { user, plan } = await setupUserWithPlan(); - const insertEntry = async (date: string) => - await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date, note: '' }); - const readDates = async () => - (await storedRows(VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' }, ['date']) as { date: string }[]).map( - (r) => r.date, - ); - - await insertEntry('2026-07-12'); - await insertEntry('2026-07-11'); - await insertEntry('2026-07-10'); - - await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-01', '2026-07-20', '2026-07-02'); // offset +1 - expect((await readDates())).toEqual(['2026-07-11', '2026-07-12', '2026-07-13']); - - await deleteRows(await orm(), VacayEntries, { plan: plan.id }); - await insertEntry('2026-07-12'); - await insertEntry('2026-07-11'); - await insertEntry('2026-07-10'); - - await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-02', '2026-07-21', '2026-07-01'); // offset -1 - expect((await readDates())).toEqual(['2026-07-09', '2026-07-11', '2026-07-12']); - }); -}); +// shiftOwnerEntriesForTripWindow (VACAY-SVC-030b to 030k) lives in vacay-trip-window.test.ts. // ── getEntries / toggleEntry ──────────────────────────────────────────────────