mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
6cf5760383
Nine fire-and-forget sends reached notifications.bridge through
import('...').then(({ send }) => ...). The laziness was working around a
module cycle that no longer exists, and it cost twice: it hid the edge from
the module graph, and it handed each send a NotificationsService built
outside the container rather than the container's own.
The sweep needed a method rather than a grep. tests/ sits outside
tsconfig's include, so a service that gains a constructor parameter does not
break its hand-wired call sites at compile time — the argument arrives as
undefined and throws inside the send. Adding tests to a throwaway tsconfig
and diffing the errors before and after named all 46 sites exactly. The same
config reports 288 pre-existing errors in the suites, almost all deliberate
partial constructions in the harnesses, which is why tests/ is not in the
build's include; that cleanup is its own piece of work and is written down
in the nest README.
notifications.instance.ts holds the out-of-container instance, apart from
notifications.bridge.ts on purpose: several suites replace the bridge
wholesale with vi.mock(..., () => ({ send })), and a sibling bridge taking
its instance from there would break under those mocks.
The suites that assert on a send now hand their spy to the injected service.
The reservations e2e overrides the provider, because its app resolves the
real one out of its own container and never saw the module mock.
1458 lines
60 KiB
TypeScript
1458 lines
60 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB setup (real in-memory SQLite) ─────────────────────────────────────────
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
canAccessTrip: () => null,
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-secret',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
// Mock websocket so notifyPlanUsers doesn't throw
|
||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||
// shareCalendar fires a notification after inserting — keep that out of unit scope
|
||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createUser } from '../../helpers/factories';
|
||
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { VacayService } from '../../../src/nest/vacay/vacay.service';
|
||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||
import { notificationsStub } from '../../helpers/notifications';
|
||
|
||
// VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy
|
||
// tests/unit/services/vacayService.test.ts (the named-function imports became
|
||
// method calls on a directly constructed VacayService; the legacy
|
||
// updateUserYearSettings is the class's updateYearSettings).
|
||
// VACAY-SVC-067 (vacay.bridge delegation) died with the bridge — its only
|
||
// consumer, the legacy tripService, folded into the DI-native TripsService.
|
||
const svc = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
|
||
|
||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||
|
||
beforeAll(async () => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
// Warm the (mocked) notificationService module: sendInvite/shareCalendar do a
|
||
// fire-and-forget dynamic import of it, and a cold load can otherwise race the
|
||
// worker teardown ("Cannot load ... after the environment was torn down").
|
||
await import('../../../src/nest/notifications/notifications.bridge');
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
// Stub fetch with empty holiday list by default so updatePlan / applyHolidayCalendars
|
||
// never makes real network calls.
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [],
|
||
}));
|
||
});
|
||
|
||
afterAll(() => {
|
||
vi.unstubAllGlobals();
|
||
testDb.close();
|
||
});
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Insert a vacay_plan_members row directly (no service factory for it). */
|
||
function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): void {
|
||
testDb.prepare(
|
||
"INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)"
|
||
).run(planId, userId, status);
|
||
}
|
||
|
||
/** Fast helper: create a user and immediately materialise their own plan. */
|
||
function setupUserWithPlan() {
|
||
const { user } = createUser(testDb);
|
||
const plan = svc.getOwnPlan(user.id);
|
||
return { user, plan };
|
||
}
|
||
|
||
// ── getOwnPlan ────────────────────────────────────────────────────────────────
|
||
|
||
describe('getOwnPlan', () => {
|
||
it('VACAY-SVC-001: creates a new plan on first call for a fresh user', () => {
|
||
const { user } = createUser(testDb);
|
||
const plan = svc.getOwnPlan(user.id);
|
||
|
||
expect(plan).toBeDefined();
|
||
expect(plan.owner_id).toBe(user.id);
|
||
expect(plan.id).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('VACAY-SVC-002: returns the same plan on a second call (idempotent)', () => {
|
||
const { user } = createUser(testDb);
|
||
const first = svc.getOwnPlan(user.id);
|
||
const second = svc.getOwnPlan(user.id);
|
||
|
||
expect(second.id).toBe(first.id);
|
||
});
|
||
|
||
it('VACAY-SVC-003: seeds the current year row in vacay_years after plan creation', () => {
|
||
const { user } = createUser(testDb);
|
||
const plan = svc.getOwnPlan(user.id);
|
||
const yr = new Date().getFullYear();
|
||
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
|
||
.get(plan.id, yr);
|
||
|
||
expect(row).toBeDefined();
|
||
});
|
||
|
||
it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', () => {
|
||
const { user } = createUser(testDb);
|
||
const plan = svc.getOwnPlan(user.id);
|
||
const yr = new Date().getFullYear();
|
||
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||
.get(user.id, plan.id, yr) as { vacation_days: number } | undefined;
|
||
|
||
expect(row).toBeDefined();
|
||
expect(row!.vacation_days).toBe(30);
|
||
});
|
||
});
|
||
|
||
// ── getActivePlan ─────────────────────────────────────────────────────────────
|
||
|
||
describe('getActivePlan', () => {
|
||
it('VACAY-SVC-005: returns own plan when user has no accepted membership in another plan', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const active = svc.getActivePlan(user.id);
|
||
|
||
expect(active.id).toBe(plan.id);
|
||
expect(active.owner_id).toBe(user.id);
|
||
});
|
||
|
||
it('VACAY-SVC-006: returns the shared plan when user has an accepted membership in another plan', () => {
|
||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
// Make sure member also has their own plan materialised first
|
||
svc.getOwnPlan(member.id);
|
||
|
||
insertMember(ownerPlan.id, member.id, 'accepted');
|
||
|
||
const active = svc.getActivePlan(member.id);
|
||
expect(active.id).toBe(ownerPlan.id);
|
||
});
|
||
|
||
it('VACAY-SVC-007: pending membership does NOT override own plan as active', () => {
|
||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
svc.getOwnPlan(member.id);
|
||
|
||
insertMember(ownerPlan.id, member.id, 'pending');
|
||
|
||
const active = svc.getActivePlan(member.id);
|
||
// Should still point to member's own plan
|
||
expect(active.owner_id).toBe(member.id);
|
||
});
|
||
});
|
||
|
||
// ── getPlanUsers ──────────────────────────────────────────────────────────────
|
||
|
||
describe('getPlanUsers', () => {
|
||
it('VACAY-SVC-008: returns [owner] for a solo plan', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const users = svc.getPlanUsers(plan.id);
|
||
|
||
expect(users).toHaveLength(1);
|
||
expect(users[0].id).toBe(user.id);
|
||
});
|
||
|
||
it('VACAY-SVC-009: returns [owner, member] after an accepted membership is inserted', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
insertMember(plan.id, member.id, 'accepted');
|
||
|
||
const users = svc.getPlanUsers(plan.id);
|
||
|
||
expect(users).toHaveLength(2);
|
||
expect(users.map(u => u.id)).toContain(owner.id);
|
||
expect(users.map(u => u.id)).toContain(member.id);
|
||
});
|
||
|
||
it('VACAY-SVC-010: pending membership members are NOT included in plan users', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const { user: pendingUser } = createUser(testDb);
|
||
insertMember(plan.id, pendingUser.id, 'pending');
|
||
|
||
const users = svc.getPlanUsers(plan.id);
|
||
expect(users.map(u => u.id)).not.toContain(pendingUser.id);
|
||
});
|
||
|
||
it('VACAY-SVC-011: returns empty array for a non-existent plan id', () => {
|
||
const users = svc.getPlanUsers(99999);
|
||
expect(users).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ── migrateHolidayCalendars ───────────────────────────────────────────────────
|
||
|
||
describe('migrateHolidayCalendars', () => {
|
||
it('VACAY-SVC-012: does nothing when holidays_enabled is falsy', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const planRow = { ...plan, holidays_enabled: 0, holidays_region: 'DE' };
|
||
|
||
await svc.migrateHolidayCalendars(plan.id, planRow);
|
||
|
||
const rows = testDb
|
||
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
|
||
.all(plan.id);
|
||
expect(rows).toHaveLength(0);
|
||
});
|
||
|
||
it('VACAY-SVC-013: inserts a calendar row when holidays_enabled=1 and holidays_region is set', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'DE' };
|
||
|
||
await svc.migrateHolidayCalendars(plan.id, planRow);
|
||
|
||
const rows = testDb
|
||
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
|
||
.all(plan.id) as { region: string }[];
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].region).toBe('DE');
|
||
});
|
||
|
||
it('VACAY-SVC-014: does nothing if a calendar row already exists (no duplicate)', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'FR' };
|
||
|
||
await svc.migrateHolidayCalendars(plan.id, planRow);
|
||
// Call a second time — should NOT insert another row
|
||
await svc.migrateHolidayCalendars(plan.id, planRow);
|
||
|
||
const rows = testDb
|
||
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
|
||
.all(plan.id);
|
||
expect(rows).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
// ── updatePlan ────────────────────────────────────────────────────────────────
|
||
|
||
describe('updatePlan', () => {
|
||
it('VACAY-SVC-015: updates block_weekends flag', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
await svc.updatePlan(plan.id, { block_weekends: true }, undefined);
|
||
|
||
const updated = testDb
|
||
.prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?')
|
||
.get(plan.id) as { block_weekends: number };
|
||
expect(updated.block_weekends).toBe(1);
|
||
});
|
||
|
||
it('VACAY-SVC-016: updates holidays_enabled flag', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
await svc.updatePlan(plan.id, { holidays_enabled: true }, undefined);
|
||
|
||
const updated = testDb
|
||
.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?')
|
||
.get(plan.id) as { holidays_enabled: number };
|
||
expect(updated.holidays_enabled).toBe(1);
|
||
});
|
||
|
||
it('VACAY-SVC-017: returns the updated plan object with boolean-coerced flags', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
const result = await svc.updatePlan(plan.id, { block_weekends: false }, undefined);
|
||
|
||
expect(result.plan.block_weekends).toBe(false);
|
||
expect(typeof result.plan.holidays_enabled).toBe('boolean');
|
||
});
|
||
|
||
it('VACAY-SVC-018: resets carried_over to 0 for all user_years when carry_over_enabled is set to false', async () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear();
|
||
|
||
// Manually set a non-zero carried_over value
|
||
testDb
|
||
.prepare('UPDATE vacay_user_years SET carried_over = 5 WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||
.run(user.id, plan.id, yr);
|
||
|
||
await svc.updatePlan(plan.id, { carry_over_enabled: false }, undefined);
|
||
|
||
const row = testDb
|
||
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||
.get(user.id, plan.id, yr) as { carried_over: number };
|
||
expect(row.carried_over).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ── addHolidayCalendar ────────────────────────────────────────────────────────
|
||
|
||
describe('addHolidayCalendar', () => {
|
||
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
const cal = svc.addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined);
|
||
|
||
expect(cal).toBeDefined();
|
||
expect(cal.id).toBeGreaterThan(0);
|
||
expect(cal.region).toBe('GB');
|
||
expect(cal.label).toBe('UK Holidays');
|
||
expect(cal.color).toBe('#ff0000');
|
||
});
|
||
|
||
it('VACAY-SVC-020: uses default color #fecaca when no color is provided', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
const cal = svc.addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined);
|
||
|
||
expect(cal.color).toBe('#fecaca');
|
||
});
|
||
});
|
||
|
||
// ── updateHolidayCalendar ─────────────────────────────────────────────────────
|
||
|
||
describe('updateHolidayCalendar', () => {
|
||
it('VACAY-SVC-021: changes label and color on an existing calendar', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const cal = svc.addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined);
|
||
|
||
const updated = svc.updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined);
|
||
|
||
expect(updated).not.toBeNull();
|
||
expect(updated!.label).toBe('Deutschland');
|
||
expect(updated!.color).toBe('#112233');
|
||
});
|
||
|
||
it('VACAY-SVC-022: returns null when the calendar id does not exist in the plan', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
const result = svc.updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined);
|
||
|
||
expect(result).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── deleteHolidayCalendar ─────────────────────────────────────────────────────
|
||
|
||
describe('deleteHolidayCalendar', () => {
|
||
it('VACAY-SVC-023: removes the calendar row and returns true on success', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const cal = svc.addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined);
|
||
|
||
const result = svc.deleteHolidayCalendar(cal.id, plan.id, undefined);
|
||
|
||
expect(result).toBe(true);
|
||
const row = testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE id = ?').get(cal.id);
|
||
expect(row).toBeUndefined();
|
||
});
|
||
|
||
it('VACAY-SVC-024: returns false when the calendar does not exist', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
const result = svc.deleteHolidayCalendar(99999, plan.id, undefined);
|
||
|
||
expect(result).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── setUserColor ──────────────────────────────────────────────────────────────
|
||
|
||
describe('setUserColor', () => {
|
||
it('VACAY-SVC-025: inserts a color for a user in a plan', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
svc.setUserColor(user.id, plan.id, '#123456', undefined);
|
||
|
||
const row = testDb
|
||
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
|
||
.get(user.id, plan.id) as { color: string } | undefined;
|
||
expect(row?.color).toBe('#123456');
|
||
});
|
||
|
||
it('VACAY-SVC-026: updates the color when called a second time (upsert)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
svc.setUserColor(user.id, plan.id, '#aaaaaa', undefined);
|
||
|
||
svc.setUserColor(user.id, plan.id, '#bbbbbb', undefined);
|
||
|
||
const row = testDb
|
||
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
|
||
.get(user.id, plan.id) as { color: string };
|
||
expect(row.color).toBe('#bbbbbb');
|
||
});
|
||
});
|
||
|
||
// ── listYears / addYear / deleteYear ──────────────────────────────────────────
|
||
|
||
describe('listYears', () => {
|
||
it('VACAY-SVC-027: returns the seeded current year for a freshly created plan', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear();
|
||
|
||
const years = svc.listYears(plan.id);
|
||
|
||
expect(years).toContain(yr);
|
||
});
|
||
});
|
||
|
||
describe('addYear', () => {
|
||
it('VACAY-SVC-028: inserts a new year and creates a user_year record', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const newYear = new Date().getFullYear() + 2;
|
||
|
||
svc.addYear(plan.id, newYear, undefined);
|
||
|
||
const years = svc.listYears(plan.id);
|
||
expect(years).toContain(newYear);
|
||
|
||
const userYear = testDb
|
||
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||
.get(user.id, plan.id, newYear) as { vacation_days: number } | undefined;
|
||
expect(userYear).toBeDefined();
|
||
expect(userYear!.vacation_days).toBe(30);
|
||
});
|
||
|
||
it('VACAY-SVC-029: carries over remaining days to the new year when carry_over_enabled is true', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const currentYear = new Date().getFullYear();
|
||
const nextYear = currentYear + 1;
|
||
|
||
// Enable carry-over and seed some entries for the current year
|
||
testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id);
|
||
// Ensure current year row exists with 10 vacation days
|
||
testDb.prepare(`
|
||
INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over)
|
||
VALUES (?, ?, ?, 10, 0)
|
||
`).run(user.id, plan.id, currentYear);
|
||
// Add 3 entries (used days) in the current year
|
||
for (let day = 1; day <= 3; day++) {
|
||
const dateStr = `${currentYear}-06-0${day}`;
|
||
testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, '');
|
||
}
|
||
|
||
svc.addYear(plan.id, nextYear, undefined);
|
||
|
||
const userYear = testDb
|
||
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||
.get(user.id, plan.id, nextYear) as { carried_over: number } | undefined;
|
||
// 10 vacation days - 3 used = 7 carried over
|
||
expect(userYear?.carried_over).toBe(7);
|
||
});
|
||
});
|
||
|
||
describe('deleteYear', () => {
|
||
it('VACAY-SVC-030: removes the year row and its associated entries', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const targetYear = new Date().getFullYear() + 3;
|
||
|
||
svc.addYear(plan.id, targetYear, undefined);
|
||
// Insert an entry for that year
|
||
testDb
|
||
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
|
||
.run(plan.id, user.id, `${targetYear}-07-15`, '');
|
||
|
||
svc.deleteYear(plan.id, targetYear, undefined);
|
||
|
||
const yearRow = testDb
|
||
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
|
||
.get(plan.id, targetYear);
|
||
expect(yearRow).toBeUndefined();
|
||
|
||
const entries = testDb
|
||
.prepare("SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?")
|
||
.all(plan.id, `${targetYear}-%`);
|
||
expect(entries).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ── getEntries / toggleEntry ──────────────────────────────────────────────────
|
||
|
||
describe('getEntries', () => {
|
||
it('VACAY-SVC-031: returns empty entries and companyHolidays for a new plan+year', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear().toString();
|
||
|
||
const result = svc.getEntries(plan.id, yr);
|
||
|
||
expect(result.entries).toEqual([]);
|
||
expect(result.companyHolidays).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('toggleEntry', () => {
|
||
it('VACAY-SVC-032: adds an entry on first call (action: added)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
const result = svc.toggleEntry(user.id, plan.id, '2025-08-01', undefined);
|
||
|
||
expect(result.action).toBe('added');
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||
.get(user.id, plan.id, '2025-08-01');
|
||
expect(row).toBeDefined();
|
||
});
|
||
|
||
it('VACAY-SVC-033: removes the entry on second call (action: removed)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2025-08-02', undefined);
|
||
const result = svc.toggleEntry(user.id, plan.id, '2025-08-02', undefined);
|
||
|
||
expect(result.action).toBe('removed');
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||
.get(user.id, plan.id, '2025-08-02');
|
||
expect(row).toBeUndefined();
|
||
});
|
||
|
||
it('VACAY-SVC-033a: logs a half day when fraction is 0.5 (#552)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
const result = svc.toggleEntry(user.id, plan.id, '2025-08-05', 0.5);
|
||
|
||
expect(result).toMatchObject({ action: 'added', fraction: 0.5 });
|
||
const row = testDb
|
||
.prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||
.get(user.id, plan.id, '2025-08-05') as { fraction: number };
|
||
expect(row.fraction).toBe(0.5);
|
||
});
|
||
|
||
it('VACAY-SVC-033b: converts a full day into a half day in place (action: updated)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2025-08-06', 1);
|
||
const result = svc.toggleEntry(user.id, plan.id, '2025-08-06', 0.5);
|
||
|
||
expect(result).toMatchObject({ action: 'updated', fraction: 0.5 });
|
||
const row = testDb
|
||
.prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||
.get(user.id, plan.id, '2025-08-06') as { fraction: number };
|
||
expect(row.fraction).toBe(0.5);
|
||
});
|
||
|
||
it('VACAY-SVC-033c: toggling the same half day again clears it (action: removed)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5);
|
||
const result = svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5);
|
||
|
||
expect(result.action).toBe('removed');
|
||
const row = testDb
|
||
.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||
.get(user.id, plan.id, '2025-08-07');
|
||
expect(row).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ── toggleCompanyHoliday ──────────────────────────────────────────────────────
|
||
|
||
describe('toggleCompanyHoliday', () => {
|
||
it('VACAY-SVC-034: adds a company holiday on first call (action: added)', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
const result = svc.toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined);
|
||
|
||
expect(result.action).toBe('added');
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
|
||
.get(plan.id, '2025-12-25');
|
||
expect(row).toBeDefined();
|
||
});
|
||
|
||
it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
|
||
svc.toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined);
|
||
const result = svc.toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined);
|
||
|
||
expect(result.action).toBe('removed');
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
|
||
.get(plan.id, '2025-12-26');
|
||
expect(row).toBeUndefined();
|
||
});
|
||
|
||
it('VACAY-SVC-036: adding a company holiday removes any existing vacay_entry on that date', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
|
||
// First add a personal entry on that date
|
||
svc.toggleEntry(user.id, plan.id, '2025-05-01', undefined);
|
||
|
||
// Now declare it a company holiday — the personal entry should be wiped
|
||
svc.toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined);
|
||
|
||
const personalEntry = testDb
|
||
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
|
||
.get(plan.id, '2025-05-01');
|
||
expect(personalEntry).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ── acceptInvite / declineInvite / cancelInvite ───────────────────────────────
|
||
|
||
describe('acceptInvite', () => {
|
||
it('VACAY-SVC-037: changes membership status to accepted', () => {
|
||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||
const { user: invitee } = createUser(testDb);
|
||
svc.getOwnPlan(invitee.id); // ensure own plan exists for data migration path
|
||
insertMember(ownerPlan.id, invitee.id, 'pending');
|
||
|
||
const result = svc.acceptInvite(invitee.id, ownerPlan.id, undefined);
|
||
|
||
expect(result.error).toBeUndefined();
|
||
const row = testDb
|
||
.prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||
.get(ownerPlan.id, invitee.id) as { status: string } | undefined;
|
||
expect(row?.status).toBe('accepted');
|
||
});
|
||
|
||
it('VACAY-SVC-038: returns 404 error when there is no pending invite', () => {
|
||
const { user } = createUser(testDb);
|
||
|
||
const result = svc.acceptInvite(user.id, 99999, undefined);
|
||
|
||
expect(result.status).toBe(404);
|
||
expect(result.error).toBeDefined();
|
||
});
|
||
|
||
it('VACAY-SVC-039: accepted member becomes visible via getActivePlan', () => {
|
||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||
const { user: invitee } = createUser(testDb);
|
||
svc.getOwnPlan(invitee.id);
|
||
insertMember(ownerPlan.id, invitee.id, 'pending');
|
||
|
||
svc.acceptInvite(invitee.id, ownerPlan.id, undefined);
|
||
|
||
const active = svc.getActivePlan(invitee.id);
|
||
expect(active.id).toBe(ownerPlan.id);
|
||
});
|
||
});
|
||
|
||
describe('declineInvite', () => {
|
||
it('VACAY-SVC-040: removes the pending invite row', () => {
|
||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||
const { user: invitee } = createUser(testDb);
|
||
insertMember(ownerPlan.id, invitee.id, 'pending');
|
||
|
||
svc.declineInvite(invitee.id, ownerPlan.id, undefined);
|
||
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||
.get(ownerPlan.id, invitee.id);
|
||
expect(row).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('cancelInvite', () => {
|
||
it('VACAY-SVC-041: removes the pending invite when owner cancels it', () => {
|
||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||
const { user: target } = createUser(testDb);
|
||
insertMember(ownerPlan.id, target.id, 'pending');
|
||
|
||
svc.cancelInvite(ownerPlan.id, target.id);
|
||
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||
.get(ownerPlan.id, target.id);
|
||
expect(row).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ── getAvailableUsers ─────────────────────────────────────────────────────────
|
||
|
||
describe('getAvailableUsers', () => {
|
||
it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: unrelated } = createUser(testDb);
|
||
svc.getOwnPlan(unrelated.id);
|
||
|
||
const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[];
|
||
|
||
expect(available.map(u => u.id)).toContain(unrelated.id);
|
||
// Owner themselves should NOT appear (excluded by u.id != ?)
|
||
expect(available.map(u => u.id)).not.toContain(owner.id);
|
||
});
|
||
|
||
it('VACAY-SVC-043: excludes users who already have an accepted membership in any plan', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: alreadyFused } = createUser(testDb);
|
||
const { plan: otherPlan } = setupUserWithPlan();
|
||
insertMember(otherPlan.id, alreadyFused.id, 'accepted');
|
||
|
||
const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[];
|
||
|
||
expect(available.map(u => u.id)).not.toContain(alreadyFused.id);
|
||
});
|
||
});
|
||
|
||
// ── getStats ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('getStats', () => {
|
||
it('VACAY-SVC-044: returns per-user stats with correct fields', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear();
|
||
|
||
const stats = svc.getStats(plan.id, yr);
|
||
|
||
expect(stats).toHaveLength(1);
|
||
expect(stats[0]).toMatchObject({
|
||
user_id: user.id,
|
||
year: yr,
|
||
vacation_days: 30,
|
||
used: 0,
|
||
remaining: 30,
|
||
});
|
||
});
|
||
|
||
it('VACAY-SVC-045: used reflects the actual number of entries for that user and year', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear();
|
||
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined);
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined);
|
||
|
||
const stats = svc.getStats(plan.id, yr);
|
||
|
||
expect(stats[0].used).toBe(2);
|
||
expect(stats[0].remaining).toBe(28);
|
||
});
|
||
|
||
it('VACAY-SVC-045a: half days count as 0.5 toward the used total (#552)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear();
|
||
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-12`, 1); // full day
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-13`, 0.5); // half day
|
||
|
||
const stats = svc.getStats(plan.id, yr);
|
||
|
||
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();
|
||
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation');
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp');
|
||
|
||
const stats = svc.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();
|
||
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp');
|
||
|
||
const stats = svc.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();
|
||
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-17`, 1, 'comp');
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-18`, 0.5, 'comp');
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-19`, 1, 'vacation');
|
||
|
||
const stats = svc.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();
|
||
|
||
svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation');
|
||
expect(svc.getStats(plan.id, yr)[0].used).toBe(1);
|
||
|
||
const result = svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'comp');
|
||
|
||
expect(result).toMatchObject({ action: 'updated', kind: 'comp' });
|
||
expect(svc.getStats(plan.id, yr)[0].used).toBe(0);
|
||
});
|
||
|
||
it('VACAY-SVC-045f: getStats reports the window it counted over (#737)', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 });
|
||
|
||
const stats = svc.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(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'calendar' });
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 });
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'anniversary', hire_date: '2019-09-16' });
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'anniversary' });
|
||
|
||
expect(svc.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.
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 });
|
||
svc.updateYearSettings(user.id, { year_type: 'anniversary', year_start_month: 4, year_start_day: 1, hire_date: null });
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'anniversary', hire_date: '2024-02-29' });
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 2, year_start_day: 31 });
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
|
||
expect(svc.resolveYearWindow(user.id, 2025).end).toBe(svc.resolveYearWindow(user.id, 2026).start);
|
||
});
|
||
});
|
||
|
||
describe('updateUserYearSettings', () => {
|
||
it('VACAY-SVC-045m: upserts, so a second call replaces the first', () => {
|
||
const { user } = setupUserWithPlan();
|
||
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 });
|
||
const saved = svc.updateYearSettings(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 = svc.updateYearSettings(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 = svc.updateYearSettings(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(svc.updateYearSettings(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(svc.getUserYearSettings(user.id)).toBeUndefined();
|
||
expect(svc.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(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
|
||
expect(svc.currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2025);
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside, first half
|
||
svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, second half
|
||
|
||
expect(svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // last day of the previous period
|
||
svc.toggleEntry(user.id, plan.id, '2027-07-01', 1, 'vacation'); // first day of the next period
|
||
|
||
expect(svc.getStats(plan.id, 2026)[0].used).toBe(0);
|
||
expect(svc.getStats(plan.id, 2025)[0].used).toBe(1);
|
||
expect(svc.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();
|
||
svc.updateYearSettings(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.
|
||
svc.toggleEntry(user.id, plan.id, '2030-09-01', 1, 'vacation');
|
||
svc.toggleEntry(user.id, plan.id, '2031-02-01', 1, 'vacation');
|
||
|
||
svc.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();
|
||
svc.updateYearSettings(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);
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2031-02-01', 1, 'comp');
|
||
|
||
svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
svc.addYear(plan.id, 2026, undefined);
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside
|
||
svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, next calendar year
|
||
svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period, must survive
|
||
|
||
svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation');
|
||
svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation');
|
||
svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period
|
||
|
||
const result = svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 });
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation');
|
||
svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation');
|
||
|
||
const result = svc.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();
|
||
svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 });
|
||
|
||
svc.toggleEntry(user.id, plan.id, '2026-04-02', 1, 'vacation'); // rendered, but counted in the previous period
|
||
|
||
expect((svc.getEntries(plan.id, '2026', user.id).entries as unknown[])).toHaveLength(1);
|
||
expect(svc.getStats(plan.id, 2026)[0].used).toBe(0);
|
||
expect(svc.getStats(plan.id, 2025)[0].used).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ── applyHolidayCalendars ─────────────────────────────────────────────────────
|
||
|
||
describe('applyHolidayCalendars', () => {
|
||
it('VACAY-SVC-046: does nothing when holidays_enabled is 0 (fetch is never called)', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
// holidays_enabled defaults to 0
|
||
|
||
await svc.applyHolidayCalendars(plan.id);
|
||
|
||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('VACAY-SVC-047: deletes matching vacay_entries for a global holiday date returned by the API', async () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const yr = new Date().getFullYear();
|
||
|
||
// Enable holidays and add a calendar
|
||
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id);
|
||
svc.addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined);
|
||
|
||
// Add a vacay entry on the holiday date
|
||
const holidayDate = `${yr}-01-01`;
|
||
testDb
|
||
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
|
||
.run(plan.id, user.id, holidayDate, '');
|
||
|
||
// Override fetch to return one global holiday matching that entry
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [{ date: holidayDate, global: true }],
|
||
}));
|
||
|
||
await svc.applyHolidayCalendars(plan.id);
|
||
|
||
const remaining = testDb
|
||
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
|
||
.all(plan.id, holidayDate);
|
||
expect(remaining).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ── Read-only calendar shares (#444/#667) ─────────────────────────────────────
|
||
|
||
describe('shareCalendar', () => {
|
||
it('VACAY-SVC-048: inserts a share row and returns no error', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: target } = createUser(testDb);
|
||
|
||
const result = svc.shareCalendar(owner.id, owner.email, target.id);
|
||
|
||
expect(result.error).toBeUndefined();
|
||
const row = testDb
|
||
.prepare('SELECT * FROM vacay_shares WHERE owner_id = ? AND user_id = ?')
|
||
.get(owner.id, target.id);
|
||
expect(row).toBeDefined();
|
||
});
|
||
|
||
it('VACAY-SVC-049: returns 400 when sharing with yourself', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
|
||
const result = svc.shareCalendar(owner.id, owner.email, owner.id);
|
||
|
||
expect(result).toEqual({ error: 'Cannot share with yourself', status: 400 });
|
||
});
|
||
|
||
it('VACAY-SVC-050: returns 404 when the target user does not exist', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
|
||
const result = svc.shareCalendar(owner.id, owner.email, 99999);
|
||
|
||
expect(result).toEqual({ error: 'User not found', status: 404 });
|
||
});
|
||
|
||
it('VACAY-SVC-051: returns 400 when the share already exists', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: target } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, target.id);
|
||
|
||
const result = svc.shareCalendar(owner.id, owner.email, target.id);
|
||
|
||
expect(result).toEqual({ error: 'Already shared', status: 400 });
|
||
});
|
||
|
||
it('VACAY-SVC-052: returns 400 when the target is already a member of the owner plan', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
insertMember(plan.id, member.id, 'accepted');
|
||
|
||
const result = svc.shareCalendar(owner.id, owner.email, member.id);
|
||
|
||
expect(result).toEqual({ error: 'User is already in your calendar', status: 400 });
|
||
});
|
||
});
|
||
|
||
describe('listShares', () => {
|
||
it('VACAY-SVC-053: outgoing rows carry the target user info', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: target } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, target.id);
|
||
|
||
const result = svc.listShares(owner.id);
|
||
|
||
expect(result.outgoing).toHaveLength(1);
|
||
expect(result.outgoing[0]).toMatchObject({
|
||
user_id: target.id,
|
||
username: target.username,
|
||
});
|
||
expect(result.outgoing[0]).not.toHaveProperty('email');
|
||
expect(result.incoming).toEqual([]);
|
||
});
|
||
|
||
it('VACAY-SVC-054: incoming rows carry the owner info, their color and a boolean hidden flag', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
svc.setUserColor(owner.id, plan.id, '#ef4444', undefined);
|
||
const { user: viewer } = setupUserWithPlan();
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
|
||
const result = svc.listShares(viewer.id);
|
||
|
||
expect(result.outgoing).toEqual([]);
|
||
expect(result.incoming).toHaveLength(1);
|
||
expect(result.incoming[0]).toMatchObject({
|
||
owner_id: owner.id,
|
||
username: owner.username,
|
||
color: '#ef4444',
|
||
hidden: false,
|
||
});
|
||
expect(result.incoming[0]).not.toHaveProperty('email');
|
||
});
|
||
|
||
it('VACAY-SVC-055: remaps colors when two sharing owners sit on the default indigo', () => {
|
||
const { user: viewer } = setupUserWithPlan(); // viewer's own color is #6366f1
|
||
const { user: owner1 } = setupUserWithPlan(); // default #6366f1
|
||
const { user: owner2 } = setupUserWithPlan(); // default #6366f1
|
||
svc.shareCalendar(owner1.id, owner1.email, viewer.id);
|
||
svc.shareCalendar(owner2.id, owner2.email, viewer.id);
|
||
|
||
const { incoming } = svc.listShares(viewer.id);
|
||
|
||
expect(incoming).toHaveLength(2);
|
||
// Both collide with the viewer's own indigo, so each gets a distinct free preset
|
||
expect(incoming[0].color).not.toBe('#6366f1');
|
||
expect(incoming[1].color).not.toBe('#6366f1');
|
||
expect(incoming[0].color).not.toBe(incoming[1].color);
|
||
});
|
||
});
|
||
|
||
describe('removeShare', () => {
|
||
it('VACAY-SVC-056: the owner can revoke their share', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
const shareId = svc.listShares(owner.id).outgoing[0].id as number;
|
||
|
||
expect(svc.removeShare(shareId, owner.id)).toBe(true);
|
||
const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId);
|
||
expect(row).toBeUndefined();
|
||
});
|
||
|
||
it('VACAY-SVC-057: the recipient can remove a share they received', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
const shareId = svc.listShares(viewer.id).incoming[0].id;
|
||
|
||
expect(svc.removeShare(shareId, viewer.id)).toBe(true);
|
||
});
|
||
|
||
it('VACAY-SVC-058: a third user cannot remove the share, unknown ids return false', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
const { user: stranger } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
const shareId = svc.listShares(owner.id).outgoing[0].id as number;
|
||
|
||
expect(svc.removeShare(shareId, stranger.id)).toBe(false);
|
||
const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId);
|
||
expect(row).toBeDefined();
|
||
|
||
expect(svc.removeShare(99999, owner.id)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('setShareHidden', () => {
|
||
it('VACAY-SVC-059: the recipient can hide and unhide the shared calendar', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
const shareId = svc.listShares(viewer.id).incoming[0].id;
|
||
|
||
expect(svc.setShareHidden(shareId, viewer.id, true)).toBe(true);
|
||
let row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number };
|
||
expect(row.hidden).toBe(1);
|
||
expect(svc.listShares(viewer.id).incoming[0].hidden).toBe(true);
|
||
|
||
expect(svc.setShareHidden(shareId, viewer.id, false)).toBe(true);
|
||
row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number };
|
||
expect(row.hidden).toBe(0);
|
||
});
|
||
|
||
it('VACAY-SVC-060: the owner cannot toggle the recipient hidden flag', () => {
|
||
const { user: owner } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
const shareId = svc.listShares(owner.id).outgoing[0].id as number;
|
||
|
||
expect(svc.setShareHidden(shareId, owner.id, true)).toBe(false);
|
||
const row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number };
|
||
expect(row.hidden).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('getShareAvailableUsers', () => {
|
||
it('VACAY-SVC-061: excludes self, already-shared users and plan members', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
insertMember(plan.id, member.id, 'accepted');
|
||
const { user: shared } = createUser(testDb);
|
||
svc.shareCalendar(owner.id, owner.email, shared.id);
|
||
const { user: unrelated } = createUser(testDb);
|
||
|
||
const ids = (svc.getShareAvailableUsers(owner.id) as { id: number }[]).map(u => u.id);
|
||
|
||
expect(ids).toContain(unrelated.id);
|
||
expect(ids).not.toContain(owner.id);
|
||
expect(ids).not.toContain(member.id);
|
||
expect(ids).not.toContain(shared.id);
|
||
});
|
||
});
|
||
|
||
describe('getSharedCalendars', () => {
|
||
it('VACAY-SVC-062: returns only the owner entries of the shared plan, including fractions', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
insertMember(plan.id, member.id, 'accepted');
|
||
const { user: viewer } = setupUserWithPlan();
|
||
svc.toggleEntry(owner.id, plan.id, '2025-06-10', 1);
|
||
svc.toggleEntry(owner.id, plan.id, '2025-06-11', 0.5);
|
||
svc.toggleEntry(member.id, plan.id, '2025-06-12', 1);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
|
||
const calendars = svc.getSharedCalendars(viewer.id, '2025');
|
||
|
||
expect(calendars).toHaveLength(1);
|
||
expect(calendars[0].owner_id).toBe(owner.id);
|
||
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, kind: 'vacation' },
|
||
{ date: '2025-06-11', fraction: 0.5, kind: 'vacation' },
|
||
]);
|
||
});
|
||
|
||
it('VACAY-SVC-063: company holidays stay hidden while the owner plan has them disabled', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 0 WHERE id = ?').run(plan.id);
|
||
svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
|
||
const calendars = svc.getSharedCalendars(viewer.id, '2025');
|
||
|
||
expect(calendars[0].companyHolidays).toEqual([]);
|
||
});
|
||
|
||
it('VACAY-SVC-064: company holidays appear once the owner plan enables them', () => {
|
||
const { user: owner, plan } = setupUserWithPlan();
|
||
const { user: viewer } = createUser(testDb);
|
||
testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 1 WHERE id = ?').run(plan.id);
|
||
svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
|
||
const calendars = svc.getSharedCalendars(viewer.id, '2025');
|
||
|
||
expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24' }]);
|
||
});
|
||
|
||
it('VACAY-SVC-065: an owner without any plan yields empty arrays (no lazy creation)', () => {
|
||
const { user: owner } = createUser(testDb); // never touched vacay — no plan row
|
||
const { user: viewer } = createUser(testDb);
|
||
testDb.prepare('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)').run(owner.id, viewer.id);
|
||
|
||
const calendars = svc.getSharedCalendars(viewer.id, '2025');
|
||
|
||
expect(calendars).toHaveLength(1);
|
||
expect(calendars[0].entries).toEqual([]);
|
||
expect(calendars[0].companyHolidays).toEqual([]);
|
||
const plan = testDb.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(owner.id);
|
||
expect(plan).toBeUndefined();
|
||
});
|
||
|
||
it('VACAY-SVC-066: follows an owner fused into another plan', () => {
|
||
const { user: host, plan: hostPlan } = setupUserWithPlan();
|
||
const { user: owner } = createUser(testDb);
|
||
svc.getOwnPlan(owner.id);
|
||
insertMember(hostPlan.id, owner.id, 'accepted');
|
||
const { user: viewer } = createUser(testDb);
|
||
svc.toggleEntry(owner.id, hostPlan.id, '2025-03-03', 1);
|
||
svc.shareCalendar(owner.id, owner.email, viewer.id);
|
||
|
||
const calendars = svc.getSharedCalendars(viewer.id, '2025');
|
||
|
||
expect(calendars).toHaveLength(1);
|
||
expect(calendars[0].entries).toEqual([{ date: '2025-03-03', fraction: 1, kind: 'vacation' }]);
|
||
});
|
||
});
|
||
|
||
// ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ──────
|
||
|
||
describe('quirk fixes', () => {
|
||
/** A DatabaseService whose run() throws when the SQL matches, for atomicity checks. */
|
||
function failingService(match: string) {
|
||
const failingDb = new DatabaseService(testDb);
|
||
const realRun = failingDb.run.bind(failingDb);
|
||
vi.spyOn(failingDb, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
|
||
if (sql.includes(match)) throw new Error('boom');
|
||
return realRun(sql, ...params);
|
||
});
|
||
return new VacayService(failingDb, new RealtimeService(), notificationsStub());
|
||
}
|
||
|
||
it('VACAY-SVC-068: acceptInvite is atomic — a failure mid-flow rolls the status flip back', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const { user: member } = createUser(testDb);
|
||
svc.getOwnPlan(member.id);
|
||
insertMember(plan.id, member.id, 'pending');
|
||
|
||
const broken = failingService('INSERT OR IGNORE INTO vacay_user_years');
|
||
expect(() => broken.acceptInvite(member.id, plan.id, undefined)).toThrow('boom');
|
||
|
||
const row = testDb.prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(plan.id, member.id) as { status: string };
|
||
expect(row.status).toBe('pending');
|
||
});
|
||
|
||
it('VACAY-SVC-069: deleteYear is atomic — a failure mid-flow keeps the year and its entries', () => {
|
||
const { user, plan } = setupUserWithPlan();
|
||
const year = new Date().getFullYear();
|
||
svc.toggleEntry(user.id, plan.id, `${year}-03-03`, 1);
|
||
|
||
const broken = failingService('DELETE FROM vacay_user_years');
|
||
expect(() => broken.deleteYear(plan.id, year, undefined)).toThrow('boom');
|
||
|
||
expect(testDb.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(plan.id, year)).toBeDefined();
|
||
expect(testDb.prepare('SELECT id FROM vacay_entries WHERE plan_id = ?').get(plan.id)).toBeDefined();
|
||
});
|
||
|
||
it('VACAY-SVC-070: getCountries surfaces an upstream non-2xx as the fetch error and caches nothing', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
|
||
|
||
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
|
||
// Nothing cached: a retry hits the network again.
|
||
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
});
|
||
|
||
it('VACAY-SVC-071: applyHolidayCalendars honors the cache TTL', async () => {
|
||
const { plan } = setupUserWithPlan();
|
||
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id);
|
||
testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, region) VALUES (?, 'DE')").run(plan.id);
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub());
|
||
|
||
await fresh.applyHolidayCalendars(plan.id);
|
||
const afterFirst = fetchMock.mock.calls.length;
|
||
await fresh.applyHolidayCalendars(plan.id);
|
||
// Within the TTL the cached year list is reused — no new requests.
|
||
expect(fetchMock.mock.calls.length).toBe(afterFirst);
|
||
|
||
vi.useFakeTimers();
|
||
try {
|
||
vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000 + 1);
|
||
await fresh.applyHolidayCalendars(plan.id);
|
||
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirst);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('VACAY-SVC-072: addYear still no-ops on a duplicate year but propagates real errors', () => {
|
||
const { plan } = setupUserWithPlan();
|
||
const year = new Date().getFullYear();
|
||
// Duplicate: the seeded current year — silently returns the list, like before.
|
||
expect(svc.addYear(plan.id, year, undefined)).toContain(year);
|
||
|
||
const broken = failingService('INSERT OR IGNORE INTO vacay_user_years');
|
||
expect(() => broken.addYear(plan.id, year + 1, undefined)).toThrow('boom');
|
||
// And atomically: the failed year was not half-added.
|
||
expect(testDb.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(plan.id, year + 1)).toBeUndefined();
|
||
});
|
||
});
|