diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 41a15b737..88ce8453e 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -75,19 +75,19 @@ export class AdminController { // ── Users ── @Get('users') - listUsers() { return { users: this.admin.listUsers() }; } + async listUsers() { return { users: await this.admin.listUsers() }; } @Post('users') @HttpCode(201) async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) { - const result = ok(this.admin.createUser(body as Parameters[0])); + const result = ok(await this.admin.createUser(body as Parameters[0])); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails }); return { user: result.user }; } @Put('users/:id') async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) { - const result = ok(this.admin.updateUser(id, body)); + const result = ok(await this.admin.updateUser(id, body)); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } }); logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`); return { user: result.user }; @@ -110,7 +110,7 @@ export class AdminController { @Delete('users/:id/mfa') async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - const result = ok(this.admin.resetUserMfa(id, user.id)); + const result = ok(await this.admin.resetUserMfa(id, user.id)); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); return { success: true }; } @@ -280,7 +280,7 @@ export class AdminController { // ── Addons ── @Get('addons') - listAddons() { return { addons: this.admin.listAddons() }; } + async listAddons() { return { addons: await this.admin.listAddons() }; } @Put('addons/:id') async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) { diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index d66977e2c..20fb50c45 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -22,6 +22,7 @@ import { getPhotoProviderConfig } from '../memories/memories.helpers'; import { validatePassword } from '../common/passwordPolicy'; import { UserCleanupService } from '../auth/user-cleanup.service'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { AddonsService } from '../addons/addons.service'; import { RealtimeService } from '../realtime/realtime.service'; import { PasskeyService } from '../auth/passkey.service'; @@ -76,11 +77,12 @@ export class AdminService { private readonly notifications: NotificationsService, private readonly userCleanup: UserCleanupService, private readonly realtime: RealtimeService, + private readonly uow: UnitOfWork, ) {} // ── User CRUD ────────────────────────────────────────────────────────────── - listUsers() { + async listUsers() { // Guests (#1362) are accountless trip participants, not real users — keep them out // of admin user management entirely. const users = this.db.all< @@ -109,7 +111,7 @@ export class AdminService { })); } - createUser(data: { username: string; email: string; password: string; role?: string }) { + async createUser(data: { username: string; email: string; password: string; role?: string }) { const username = data.username?.trim(); const email = data.email?.trim(); const password = data.password?.trim(); @@ -151,7 +153,7 @@ export class AdminService { }; } - updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) { + async updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) { const username = typeof data.username === 'string' ? data.username.trim() : data.username; const email = typeof data.email === 'string' ? data.email.trim() : data.email; const { role, password } = data; @@ -200,7 +202,7 @@ export class AdminService { // (changePassword, resetPassword) have always done this; this one had not. const newPv = password ? ((user as User & { password_version?: number }).password_version ?? 0) + 1 : null; - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run( ` UPDATE users SET @@ -276,7 +278,7 @@ export class AdminService { * making that reachable from here would turn a stolen admin session into a * way to strip the second factor off the very account it came from. */ - resetUserMfa(id: string, actingUserId: number): { error?: string; status?: number; success?: boolean; email?: string } { + async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> { const targetId = Number(id); if (targetId === actingUserId) { return { error: 'Use Settings to change your own two-factor setup', status: 400 }; @@ -299,7 +301,7 @@ export class AdminService { // ── Stats ────────────────────────────────────────────────────────────────── - getStats() { + async getStats() { const totalUsers = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count; const totalTrips = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trips')!.count; const totalPlaces = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM places')!.count; @@ -327,7 +329,7 @@ export class AdminService { // ── Audit Log ────────────────────────────────────────────────────────────── - getAuditLog(query: { limit?: string; offset?: string }) { + async getAuditLog(query: { limit?: string; offset?: string }) { const limitRaw = Number.parseInt(String(query.limit || '100'), 10); const offsetRaw = Number.parseInt(String(query.offset || '0'), 10); const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); @@ -538,7 +540,7 @@ export class AdminService { // ── Addons ───────────────────────────────────────────────────────────────── - listAddons() { + async listAddons() { const addons = this.db .all('SELECT * FROM addons ORDER BY sort_order, id') // Hidden rather than shown-and-refused, because a toggle that answers 403 @@ -693,7 +695,7 @@ export class AdminService { return { error: 'Enable the Documents addon first', status: 409 }; } - this.db.transaction(() => { + await this.uow.transactional(async () => { if (addon) { if (data.enabled !== undefined) { this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id); diff --git a/server/src/nest/doc-sync/doc-sync-config.service.ts b/server/src/nest/doc-sync/doc-sync-config.service.ts index 77a25f307..0e29111f9 100644 --- a/server/src/nest/doc-sync/doc-sync-config.service.ts +++ b/server/src/nest/doc-sync/doc-sync-config.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import crypto from 'crypto'; import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { checkSsrf } from '../../utils/ssrfGuard'; import { DocumentProviderRegistry } from './document-provider.registry'; import type { DocumentConnectionRef, DocumentScopeRef, DocResult } from './document-provider'; @@ -86,19 +87,20 @@ export class DocSyncConfigService { constructor( private readonly db: DatabaseService, private readonly registry: DocumentProviderRegistry, + private readonly uow: UnitOfWork, ) {} // ── Provider metadata ────────────────────────────────────────────────────── /** Only providers the instance admin switched on may be configured. */ - enabledProviderIds(): string[] { + async enabledProviderIds(): Promise { return this.db.connection .prepare('SELECT id FROM document_providers WHERE enabled = 1 ORDER BY sort_order') .all() .map((r) => (r as { id: string }).id); } - providerFields(providerId: string): ProviderFieldRow[] { + async providerFields(providerId: string): Promise { // Every column the form needs, not just the three the secret bookkeeping // uses: the client renders the field from this row, and a missing `label` // turns into a literal "docsync.undefined" on screen. @@ -110,8 +112,8 @@ export class DocSyncConfigService { .all(providerId) as ProviderFieldRow[]; } - private secretKeys(providerId: string): string[] { - return this.providerFields(providerId).filter((f) => f.secret === 1).map((f) => f.field_key); + private async secretKeys(providerId: string): Promise { + return (await this.providerFields(providerId)).filter((f) => f.secret === 1).map((f) => f.field_key); } /** @@ -119,7 +121,7 @@ export class DocSyncConfigService { * admin switching its provider off, and the client only gets names for the * providers that are still on, so without this it would show the raw id. */ - private providerName(providerId: string): string { + private async providerName(providerId: string): Promise { const row = this.db.connection .prepare('SELECT name FROM document_providers WHERE id = ?') .get(providerId) as { name: string } | undefined; @@ -128,11 +130,11 @@ export class DocSyncConfigService { // ── Connections ──────────────────────────────────────────────────────────── - getConnection(id: number): ConnectionRow | undefined { + async getConnection(id: number): Promise { return this.db.connection.prepare('SELECT * FROM document_connections WHERE id = ?').get(id) as ConnectionRow | undefined; } - listConnections(tripId: number): ConnectionRow[] { + async listConnections(tripId: number): Promise { return this.db.connection .prepare('SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id') .all(tripId) as ConnectionRow[]; @@ -183,9 +185,9 @@ export class DocSyncConfigService { * caller's ref: a sync run holds its ref for minutes, and writing that * snapshot back would undo a password the owner changed in the meantime. */ - saveEarnedSecret(connectionId: number, key: string, value: string | null): void { - this.db.transaction(() => { - const row = this.getConnection(connectionId); + async saveEarnedSecret(connectionId: number, key: string, value: string | null): Promise { + await this.uow.transactional(async () => { + const row = await this.getConnection(connectionId); if (!row) return; const secrets = decryptSecrets(row.secrets); if (value === null) delete secrets[key]; @@ -197,7 +199,7 @@ export class DocSyncConfigService { } /** What a client may see: no secret values, only whether each one is set. */ - publicConnection(row: ConnectionRow): Record { + async publicConnection(row: ConnectionRow): Promise> { const ref = this.toRef(row); return { id: row.id, @@ -206,7 +208,7 @@ export class DocSyncConfigService { ownerUserId: row.owner_user_id, baseUrl: row.base_url, settings: ref.settings, - secrets: maskSecrets(ref.secrets, this.secretKeys(row.provider_id)), + secrets: maskSecrets(ref.secrets, await this.secretKeys(row.provider_id)), allowInsecureTls: row.allow_insecure_tls === 1, capabilities: row.capabilities ? safeParse(row.capabilities) : null, lastProbeAt: row.last_probe_at, @@ -266,7 +268,7 @@ export class DocSyncConfigService { const urlCheck = await this.validateBaseUrl(input.baseUrl); if (docFailed(urlCheck)) return { success: false, error: urlCheck.error }; - const fields = this.providerFields(input.providerId); + const fields = await this.providerFields(input.providerId); const secretKeys = fields.filter((f) => f.secret === 1).map((f) => f.field_key); const plainKeys = fields.filter((f) => f.secret !== 1).map((f) => f.field_key); @@ -316,7 +318,7 @@ export class DocSyncConfigService { const settingsJson = JSON.stringify(settings); const insecure = input.allowInsecureTls ? 1 : 0; - const row = this.db.transaction(() => { + const row = await this.uow.transactional(async () => { if (existing) { this.db.connection .prepare( @@ -326,7 +328,7 @@ export class DocSyncConfigService { WHERE id = ?`, ) .run(urlCheck.data.url, encrypted, settingsJson, insecure, ownerId, existing.id); - return this.getConnection(existing.id) as ConnectionRow; + return (await this.getConnection(existing.id)) as ConnectionRow; } const info = this.db.connection .prepare( @@ -334,13 +336,13 @@ export class DocSyncConfigService { VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(tripId, input.providerId, userId, urlCheck.data.url, encrypted, settingsJson, insecure); - return this.getConnection(Number(info.lastInsertRowid)) as ConnectionRow; + return (await this.getConnection(Number(info.lastInsertRowid))) as ConnectionRow; }); return { success: true, data: row }; } - recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): void { + async recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): Promise { this.db.connection .prepare( `UPDATE document_connections @@ -356,23 +358,23 @@ export class DocSyncConfigService { * more: the same promise Dawarich's disconnect makes, and the only version * of this action that is safe to offer without a confirmation dialog. */ - deleteConnection(id: number): void { + async deleteConnection(id: number): Promise { this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id); } // ── Trip bindings ────────────────────────────────────────────────────────── - listLinks(tripId: number): LinkRow[] { + async listLinks(tripId: number): Promise { return this.db.connection .prepare('SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id') .all(tripId) as LinkRow[]; } - getLink(id: number): LinkRow | undefined { + async getLink(id: number): Promise { return this.db.connection.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as LinkRow | undefined; } - getLinkByToken(token: string): LinkRow | undefined { + async getLinkByToken(token: string): Promise { return this.db.connection .prepare('SELECT * FROM trip_document_links WHERE webhook_token = ?') .get(token) as LinkRow | undefined; @@ -397,8 +399,8 @@ export class DocSyncConfigService { * still goes through the connection's own credential, so it cannot open * anything that credential could not open anyway. */ - createLink(tripId: number, userId: number, input: DocsyncLinkInput): DocResult { - const conn = this.getConnection(input.connectionId); + async createLink(tripId: number, userId: number, input: DocsyncLinkInput): Promise> { + const conn = await this.getConnection(input.connectionId); if (!conn || conn.trip_id !== tripId) { return { success: false, error: { code: 'not_found', detail: 'connection not found for this trip' } }; } @@ -436,18 +438,18 @@ export class DocSyncConfigService { String(encryptSecrets({ webhook: secret })), userId, ); - return { success: true, data: this.getLink(Number(info.lastInsertRowid)) as LinkRow }; + return { success: true, data: (await this.getLink(Number(info.lastInsertRowid))) as LinkRow }; } - updateLink(id: number, patch: Partial): LinkRow | undefined { + async updateLink(id: number, patch: Partial): Promise { const sets: string[] = []; const values: unknown[] = []; // Sync automatically is the way back for an orphaned binding, but only once // the person whose credential it runs under is on the trip again. Until // then the switch stays off rather than handing that credential back. - const current = patch.syncEnabled === true ? this.getLink(id) : undefined; + const current = patch.syncEnabled === true ? await this.getLink(id) : undefined; if (current?.last_sync_state === 'orphaned') { - if (this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined }; + if (await this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined }; else sets.push("last_sync_state = 'never'"); } if (patch.direction !== undefined) { sets.push('direction = ?'); values.push(patch.direction); } @@ -455,10 +457,10 @@ export class DocSyncConfigService { if (patch.conflictPolicy !== undefined) { sets.push('conflict_policy = ?'); values.push(patch.conflictPolicy); } if (patch.syncEnabled !== undefined) { sets.push('sync_enabled = ?'); values.push(patch.syncEnabled ? 1 : 0); } if (patch.remoteLabel !== undefined) { sets.push('remote_label = ?'); values.push(patch.remoteLabel); } - if (sets.length === 0) return this.getLink(id); + if (sets.length === 0) return await this.getLink(id); sets.push('updated_at = CURRENT_TIMESTAMP'); this.db.connection.prepare(`UPDATE trip_document_links SET ${sets.join(', ')} WHERE id = ?`).run(...values, id); - return this.getLink(id); + return await this.getLink(id); } /** @@ -466,8 +468,8 @@ export class DocSyncConfigService { * document on either side. A user who wants that does it deliberately, in * the system that holds the file. */ - deleteLink(id: number): void { - this.db.transaction(() => { + async deleteLink(id: number): Promise { + await this.uow.transactional(async () => { this.db.connection.prepare('DELETE FROM document_sync_items WHERE link_id = ?').run(id); this.db.connection.prepare('DELETE FROM trip_document_links WHERE id = ?').run(id); }); @@ -478,13 +480,13 @@ export class DocSyncConfigService { } /** The masked view a client gets, with the webhook URL it may need to paste. */ - publicLink(link: LinkRow, webhookBaseUrl: string | null): Record { + async publicLink(link: LinkRow, webhookBaseUrl: string | null): Promise> { return { id: link.id, tripId: link.trip_id, connectionId: link.connection_id, providerId: link.provider_id, - providerName: this.providerName(link.provider_id), + providerName: await this.providerName(link.provider_id), scopeKey: link.remote_scope_key, remoteRootId: link.remote_root_id, remoteRootPath: link.remote_root_path, @@ -501,7 +503,7 @@ export class DocSyncConfigService { // subscribe on its own (Papra, and Nextcloud without admin rights). Not // for a provider that takes no webhook at all: an address with nowhere // to paste it only promises what the timer delivers anyway. - webhookUrl: webhookBaseUrl && link.webhook_token && this.takesWebhook(link) + webhookUrl: webhookBaseUrl && link.webhook_token && (await this.takesWebhook(link)) ? `${webhookBaseUrl}/api/docsync/webhook/${link.webhook_token}` : null, webhookSecret: link.webhook_secret ? DOCSYNC_SECRET_MASK : null, @@ -513,8 +515,8 @@ export class DocSyncConfigService { * recorded it. Unknown counts as yes: a connection that was never probed * still gets the address, and a stale answer costs nothing but a line. */ - private takesWebhook(link: LinkRow): boolean { - const recorded = this.getConnection(link.connection_id)?.capabilities; + private async takesWebhook(link: LinkRow): Promise { + const recorded = (await this.getConnection(link.connection_id))?.capabilities; const caps = recorded ? safeParse(recorded) : null; return !(caps && typeof caps === 'object' && (caps as { push?: unknown }).push === 'none'); } @@ -525,11 +527,11 @@ export class DocSyncConfigService { * marked; every path that starts a run asks here rather than reading the * mark alone. */ - isOrphaned(link: LinkRow): boolean { - return link.last_sync_state === 'orphaned' || this.ownerLeft(link.connection_id); + async isOrphaned(link: LinkRow): Promise { + return link.last_sync_state === 'orphaned' || (await this.ownerLeft(link.connection_id)); } - private ownerLeft(connectionId: number): boolean { + private async ownerLeft(connectionId: number): Promise { return !!this.db.connection .prepare( `SELECT 1 FROM document_connections c @@ -550,7 +552,7 @@ export class DocSyncConfigService { * and a binding that keeps using an ex-member's token is the kind of thing * nobody notices until it is a complaint. */ - markOrphanedLinks(): number { + async markOrphanedLinks(): Promise { const info = this.db.connection .prepare( `UPDATE trip_document_links diff --git a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts index 10f0ba7a2..025c73361 100644 --- a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts +++ b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts @@ -74,7 +74,7 @@ export class DocSyncWebhookController implements OnModuleDestroy { @Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.') @HttpCode(200) async nudge(@Param('token') token: string, @Req() req: Request) { - const link = this.config.getLinkByToken(token); + const link = await this.config.getLinkByToken(token); // Always 200, even for an unknown token: a 404 here would let anyone probe // which tokens exist, and a provider that gets an error will retry anyway. if (!link || link.sync_enabled !== 1) return { received: true }; @@ -115,7 +115,7 @@ export class DocSyncWebhookController implements OnModuleDestroy { const timer = setTimeout(() => { void (async () => { this.pending.delete(linkId); - const fresh = reload(); + const fresh = await reload(); if (!fresh || fresh.sync_enabled !== 1) return; if (!(await this.syncIsOn(fresh))) return; void this.sync.syncLink(fresh).then((res) => { diff --git a/server/src/nest/doc-sync/doc-sync.controller.ts b/server/src/nest/doc-sync/doc-sync.controller.ts index 9b8d06c01..4b8ab1463 100644 --- a/server/src/nest/doc-sync/doc-sync.controller.ts +++ b/server/src/nest/doc-sync/doc-sync.controller.ts @@ -98,7 +98,7 @@ export class DocSyncController { * The 403 still says "owner": an admin never sees it, and to everybody who * does, the owner is the person to ask. */ - private assertCanManage(tripId: string, user: User): void { + private async assertCanManage(tripId: string, user: User): Promise { if (user.role === 'admin') return; const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId); if (!trip) throw new HttpException('Trip not found', 404); @@ -111,17 +111,19 @@ export class DocSyncController { /** Which providers this instance has switched on, with their form fields. */ @Get('providers') - providers() { + async providers() { const rows = this.db.connection .prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order') .all() as Array<{ id: string; name: string; description: string | null; icon: string }>; - return rows.map((p) => ({ - ...p, - // A provider row with no registered adapter would render a form that - // cannot work, so it is reported rather than hidden. - available: !!this.registry.get(p.id), - fields: this.config.providerFields(p.id).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })), - })); + return await Promise.all( + rows.map(async (p) => ({ + ...p, + // A provider row with no registered adapter would render a form that + // cannot work, so it is reported rather than hidden. + available: !!this.registry.get(p.id), + fields: (await this.config.providerFields(p.id)).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })), + })), + ); } @Get('status') @@ -132,8 +134,9 @@ export class DocSyncController { // ── Connections ──────────────────────────────────────────────────────────── @Get('connections') - listConnections(@Param('tripId') tripId: string) { - return this.config.listConnections(Number(tripId)).map((c) => this.config.publicConnection(c)); + async listConnections(@Param('tripId') tripId: string) { + const rows = await this.config.listConnections(Number(tripId)); + return await Promise.all(rows.map((c) => this.config.publicConnection(c))); } @Put('connections') @@ -143,8 +146,8 @@ export class DocSyncController { @CurrentUser() user: User, @Body() body: DocsyncConnectionDto, ) { - this.assertCanManage(tripId, user); - if (!this.config.enabledProviderIds().includes(body.providerId)) { + await this.assertCanManage(tripId, user); + if (!(await this.config.enabledProviderIds()).includes(body.providerId)) { throw new HttpException(`Provider: "${body.providerId}" is not enabled, contact server administrator`, 400); } const res = await this.config.upsertConnection(Number(tripId), Number(user.id), body); @@ -164,7 +167,7 @@ export class DocSyncController { @CurrentUser() user: User, @Body() body: DocsyncConnectionTestDto, ) { - this.assertCanManage(tripId, user); + await this.assertCanManage(tripId, user); const provider = this.registry.get(body.providerId); if (!provider) return { connected: false, error: 'unknown_provider' }; @@ -173,9 +176,9 @@ export class DocSyncController { // Probing with the values on screen, merged over whatever is stored, so a // user testing an unchanged connection does not have to retype the secret. - const existing = this.config - .listConnections(Number(tripId)) - .find((c) => c.provider_id === body.providerId); + const existing = (await this.config.listConnections(Number(tripId))).find( + (c) => c.provider_id === body.providerId, + ); // Only for the address the credential was stored against. Merging it into a // probe of an arbitrary baseUrl turns this route into a way to have TREK // post a stored API token at a server of the caller's choosing, which is @@ -183,7 +186,7 @@ export class DocSyncController { // reach for. Same host, same scheme, same port, or the caller types it in. const sameTarget = !!existing && sameOrigin(existing.base_url, urlCheck.data.url); const stored = sameTarget && existing ? this.config.toRef(existing) : null; - const fields = this.config.providerFields(body.providerId); + const fields = await this.config.providerFields(body.providerId); const secrets: Record = { ...(stored?.secrets ?? {}) }; const settings: Record = { ...(stored?.settings ?? {}) }; for (const f of fields) { @@ -203,7 +206,7 @@ export class DocSyncController { allowInsecureTls: body.allowInsecureTls, }); if (docFailed(res)) return { connected: false, error: res.error.code, detail: res.error.detail }; - if (existing) this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities); + if (existing) await this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities); return { connected: true, account: res.data.account, capabilities: res.data.capabilities }; } @@ -214,15 +217,15 @@ export class DocSyncController { @Param('connectionId') connectionId: string, @CurrentUser() user: User, ) { - this.assertCanManage(tripId, user); - const conn = this.config.getConnection(Number(connectionId)); + await this.assertCanManage(tripId, user); + const conn = await this.config.getConnection(Number(connectionId)); if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404); // Its bindings go with it (ON DELETE CASCADE), so read them first. Every // subscription TREK registered for one of them is taken down while the // credential is still here to do it with, as unbinding one does: left // standing, a Paperless workflow or a Nextcloud listener keeps posting to // a token that answers nothing, for good. - const unbound = this.config.listLinks(conn.trip_id).filter((l) => l.connection_id === conn.id); + const unbound = (await this.config.listLinks(conn.trip_id)).filter((l) => l.connection_id === conn.id); const provider = this.registry.get(conn.provider_id); if (provider?.unregisterWebhook) { const ref = this.config.toRef(conn); @@ -230,7 +233,7 @@ export class DocSyncController { if (link.webhook_subscription_id) await provider.unregisterWebhook(ref, link.webhook_subscription_id); } } - this.config.deleteConnection(conn.id); + await this.config.deleteConnection(conn.id); for (const link of unbound) this.announceBinding(link); return { success: true }; } @@ -245,8 +248,8 @@ export class DocSyncController { @CurrentUser() user: User, @Query('q') q?: string, ) { - this.assertCanManage(tripId, user); - const conn = this.config.getConnection(Number(connectionId)); + await this.assertCanManage(tripId, user); + const conn = await this.config.getConnection(Number(connectionId)); if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404); const provider = this.registry.get(conn.provider_id); if (!provider) throw new HttpException('Provider not available', 400); @@ -262,8 +265,8 @@ export class DocSyncController { @CurrentUser() user: User, @Body() body: DocsyncScopeCreateDto, ) { - this.assertCanManage(tripId, user); - const conn = this.config.getConnection(Number(connectionId)); + await this.assertCanManage(tripId, user); + const conn = await this.config.getConnection(Number(connectionId)); if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404); const provider = this.registry.get(conn.provider_id); if (!provider) throw new HttpException('Provider not available', 400); @@ -275,9 +278,10 @@ export class DocSyncController { // ── Bindings ─────────────────────────────────────────────────────────────── @Get('links') - listLinks(@Param('tripId') tripId: string, @Req() req: Request) { + async listLinks(@Param('tripId') tripId: string, @Req() req: Request) { const base = publicOrigin(req); - return this.config.listLinks(Number(tripId)).map((l) => this.config.publicLink(l, base)); + const rows = await this.config.listLinks(Number(tripId)); + return await Promise.all(rows.map((l) => this.config.publicLink(l, base))); } @Post('links') @@ -288,8 +292,8 @@ export class DocSyncController { @Body() body: DocsyncLinkDto, @Req() req: Request, ) { - this.assertCanManage(tripId, user); - const res = this.config.createLink(Number(tripId), Number(user.id), body); + await this.assertCanManage(tripId, user); + const res = await this.config.createLink(Number(tripId), Number(user.id), body); if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400); this.announceBinding(res.data); @@ -297,7 +301,7 @@ export class DocSyncController { // not a failure of the binding (polling still carries it), so it is logged // rather than thrown at the user; the first run that follows would write // over anything put into the link state. - const conn = this.config.getConnection(res.data.connection_id); + const conn = await this.config.getConnection(res.data.connection_id); const provider = conn ? this.registry.get(conn.provider_id) : undefined; if (conn && provider?.registerWebhook) { const base = publicOrigin(req); @@ -323,23 +327,23 @@ export class DocSyncController { // A first run right away, so the user sees something happen instead of // waiting out a poll interval and wondering whether it worked. void this.sync.syncLink(res.data, { full: true }); - return this.config.publicLink(this.config.getLink(res.data.id) ?? res.data, publicOrigin(req)); + return await this.config.publicLink((await this.config.getLink(res.data.id)) ?? res.data, publicOrigin(req)); } @Patch('links/:linkId') @HttpCode(200) - updateLink( + async updateLink( @Param('tripId') tripId: string, @Param('linkId') linkId: string, @CurrentUser() user: User, @Body() body: DocsyncLinkUpdateDto, @Req() req: Request, ) { - this.assertCanManage(tripId, user); - const link = this.config.getLink(Number(linkId)); + await this.assertCanManage(tripId, user); + const link = await this.config.getLink(Number(linkId)); if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404); - const updated = this.config.updateLink(link.id, body); - return this.config.publicLink(updated ?? link, publicOrigin(req)); + const updated = await this.config.updateLink(link.id, body); + return await this.config.publicLink(updated ?? link, publicOrigin(req)); } @Delete('links/:linkId') @@ -349,16 +353,16 @@ export class DocSyncController { @Param('linkId') linkId: string, @CurrentUser() user: User, ) { - this.assertCanManage(tripId, user); - const link = this.config.getLink(Number(linkId)); + await this.assertCanManage(tripId, user); + const link = await this.config.getLink(Number(linkId)); if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404); - const conn = this.config.getConnection(link.connection_id); + const conn = await this.config.getConnection(link.connection_id); const provider = conn ? this.registry.get(conn.provider_id) : undefined; if (conn && provider?.unregisterWebhook && link.webhook_subscription_id) { await provider.unregisterWebhook(this.config.toRef(conn), link.webhook_subscription_id); } - this.config.deleteLink(link.id); + await this.config.deleteLink(link.id); this.announceBinding(link); // Unbinding keeps both copies. Nothing is deleted anywhere. return { success: true, documentsKept: true }; @@ -371,12 +375,12 @@ export class DocSyncController { @Param('linkId') linkId: string, @Body() body: DocsyncSyncNowDto, ) { - const link = this.config.getLink(Number(linkId)); + const link = await this.config.getLink(Number(linkId)); if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404); // An orphaned binding stays orphaned: its credential belongs to somebody who // is no longer on this trip. The run itself would stand down as well; this // says why, instead of answering with an empty run. - if (this.config.isOrphaned(link)) { + if (await this.config.isOrphaned(link)) { throw new HttpException({ error: 'This binding lost its owner and has to be reconnected' }, 409); } // Refused before the shelved rows are touched, so a binding an admin @@ -387,14 +391,14 @@ export class DocSyncController { } // A person asking for a run is also asking for the rows that gave up to be // tried once more; the scheduler gets no such reprieve. - this.sync.retryShelvedItems(link.id); + await this.sync.retryShelvedItems(link.id); return this.sync.syncLink(link, { full: body.full }); } // ── Documents and conflicts ──────────────────────────────────────────────── @Get('items') - items(@Param('tripId') tripId: string, @Query('state') state?: string) { + async items(@Param('tripId') tripId: string, @Query('state') state?: string) { const rows = state ? this.db.connection .prepare( @@ -421,7 +425,7 @@ export class DocSyncController { @CurrentUser() user: User, @Body() body: DocsyncResolveConflictDto, ) { - this.assertCanManage(tripId, user); + await this.assertCanManage(tripId, user); const ok = await this.sync.resolveConflict(Number(itemId), body.keep, Number(tripId)); if (!ok) throw new HttpException('Item is not in conflict', 400); return { success: true }; diff --git a/server/src/nest/doc-sync/doc-sync.job.ts b/server/src/nest/doc-sync/doc-sync.job.ts index 68c9087ab..54adf5a8a 100644 --- a/server/src/nest/doc-sync/doc-sync.job.ts +++ b/server/src/nest/doc-sync/doc-sync.job.ts @@ -50,9 +50,9 @@ export class DocSyncJob implements OnApplicationBootstrap { /** When the last pass started; null until the first one, which is never skipped. */ private lastRunAt: number | null = null; - onApplicationBootstrap(): void { + async onApplicationBootstrap(): Promise { if (!this.registrar.isEnabled()) return; - logInfo(`Document sync: polling every ${this.intervalSeconds()}s`); + logInfo(`Document sync: polling every ${await this.intervalSeconds()}s`); this.registrar.register('docsync', '* * * * *', () => this.tick()); } @@ -64,16 +64,16 @@ export class DocSyncJob implements OnApplicationBootstrap { * boundaries fall. Comparing against the last run keeps the setting's own * resolution; the cost of the extra wake-ups is one read of app_settings. */ - private isDue(now: number): boolean { + private async isDue(now: number): Promise { // Null rather than 0: comparing against the epoch means "due" only once the // clock has passed the interval since 1970, which is true in production and // false for any test that picks a small timestamp, a difference that would // have hidden here rather than in the behaviour it is supposed to describe. if (this.lastRunAt === null) return true; - return now - this.lastRunAt >= this.intervalSeconds() * 1000; + return now - this.lastRunAt >= (await this.intervalSeconds()) * 1000; } - private intervalSeconds(): number { + private async intervalSeconds(): Promise { const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL)?.value; const parsed = Number.parseInt(raw || '', 10); if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_SECONDS; @@ -89,15 +89,15 @@ export class DocSyncJob implements OnApplicationBootstrap { if (killSwitch === 'false') return; const now = Date.now(); - if (!this.isDue(now)) return; + if (!(await this.isDue(now))) return; this.lastRunAt = now; // Cheap, and it catches a binding whose owner left the trip through a // path that has no hook to attach to: a transfer, a direct DB edit. - const orphaned = this.config.markOrphanedLinks(); + const orphaned = await this.config.markOrphanedLinks(); if (orphaned > 0) logInfo(`Document sync: ${orphaned} link(s) orphaned, owner no longer on the trip`); - const links = this.sync.dueLinks(); + const links = await this.sync.dueLinks(); for (const link of links) { // One failing provider must not stop the others: an unreachable NAS on // one trip is not a reason to skip a Paperless binding on another. diff --git a/server/src/nest/doc-sync/doc-sync.mcp.ts b/server/src/nest/doc-sync/doc-sync.mcp.ts index 4545f125b..28debb4e1 100644 --- a/server/src/nest/doc-sync/doc-sync.mcp.ts +++ b/server/src/nest/doc-sync/doc-sync.mcp.ts @@ -69,9 +69,9 @@ export class DocSyncMcp { }) async listIssues({ tripId }: { tripId: number }, ctx: McpContext) { if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); - const links = this.config.listLinks(tripId); + const links = await this.config.listLinks(tripId); if (links.length === 0) return ok({ configured: false, issues: [] }); - return ok({ configured: true, issues: this.sync.issues(tripId) }); + return ok({ configured: true, issues: await this.sync.issues(tripId) }); } @Tool({ @@ -92,7 +92,7 @@ export class DocSyncMcp { }) async syncNow({ tripId, full }: { tripId: number; full?: boolean }, ctx: McpContext) { if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); - const links = this.config.listLinks(tripId); + const links = await this.config.listLinks(tripId); if (links.length === 0) { return errorResult('This trip is not connected to a document store. Connect one in the trip\'s file manager first.'); } @@ -101,7 +101,7 @@ export class DocSyncMcp { // An orphaned binding stays stopped, as it does on the REST route and for // the scheduler: its credential belongs to somebody who has left the trip, // and a run would use it anyway. - if (this.config.isOrphaned(link)) { + if (await this.config.isOrphaned(link)) { results.push({ linkId: link.id, provider: link.provider_id, state: 'orphaned', errorCode: 'orphaned' }); continue; } @@ -116,7 +116,7 @@ export class DocSyncMcp { // that were shelved after too many failures. The REST route does the // same thing before its run; a tool that skipped it would answer "in // sync" while leaving them shelved. - this.sync.retryShelvedItems(link.id); + await this.sync.retryShelvedItems(link.id); results.push({ linkId: link.id, provider: link.provider_id, ...(await this.sync.syncLink(link, { full: full === true })) }); } if (results.every((r) => r.errorCode === PROVIDER_DISABLED)) { diff --git a/server/src/nest/doc-sync/doc-sync.service.ts b/server/src/nest/doc-sync/doc-sync.service.ts index 9c3a6a482..57a76fe10 100644 --- a/server/src/nest/doc-sync/doc-sync.service.ts +++ b/server/src/nest/doc-sync/doc-sync.service.ts @@ -109,7 +109,7 @@ export class DocSyncService { * than stood down per run. It never runs, so it never gets a `last_sync_at`, * and twenty of them would sort first on every tick and take every slot. */ - dueLinks(limit = 20): LinkRow[] { + async dueLinks(limit = 20): Promise { return this.db.connection .prepare( `SELECT * FROM trip_document_links @@ -137,7 +137,7 @@ export class DocSyncService { */ async isSwitchedOff(link: Pick): Promise { if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return true; - return !this.config.enabledProviderIds().includes(link.provider_id); + return !(await this.config.enabledProviderIds()).includes(link.provider_id); } /** @@ -163,7 +163,7 @@ export class DocSyncService { return await this.runLink(link, opts); } catch (err) { this.logger.error(`link ${link.id} failed: ${err instanceof Error ? err.message : String(err)}`); - this.recordLinkFailure(link, 'unknown'); + await this.recordLinkFailure(link, 'unknown'); return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'unknown' }; } finally { this.running.delete(link.id); @@ -182,17 +182,17 @@ export class DocSyncService { // An orphaned binding runs for nobody. Its credential belongs to somebody // who has left the trip, and a webhook or a resolved conflict reach this // without asking beforehand. - if (this.config.isOrphaned(link)) { + if (await this.config.isOrphaned(link)) { return { state: 'orphaned', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }; } - const conn = this.config.getConnection(link.connection_id); + const conn = await this.config.getConnection(link.connection_id); if (!conn) { - this.recordLinkFailure(link, 'not_found'); + await this.recordLinkFailure(link, 'not_found'); return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'not_found' }; } const provider = this.registry.get(link.provider_id); if (!provider) { - this.recordLinkFailure(link, 'provider_error'); + await this.recordLinkFailure(link, 'provider_error'); return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'provider_error' }; } @@ -204,20 +204,20 @@ export class DocSyncService { const resolved = await provider.resolveScope(ref, scope); if (docFailed(resolved)) { const code = resolved.error.code === 'not_found' || resolved.error.code === 'scope_missing' ? 'scope_missing' : resolved.error.code; - this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined); + await this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined); return { state: code === 'scope_missing' ? 'scope_lost' : 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: code }; } // Read before the listing because they decide how much of it is needed: a // file restored in TREK waits for a listing that shows its copy. - const items = this.loadItems(link.id); - const local = this.loadLocalDocuments(link); + const items = await this.loadItems(link.id); + const local = await this.loadLocalDocuments(link); if (opts.full || needsFullListing(items, local)) scope.cursor = null; const listing = await provider.list(ref, scope); if (docFailed(listing)) { const state = listing.error.code === 'unauthorized' ? 'needs_reauth' : 'failed'; - this.recordLinkFailure(link, listing.error.code, state); + await this.recordLinkFailure(link, listing.error.code, state); return { state, pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: listing.error.code }; } @@ -237,7 +237,7 @@ export class DocSyncService { // Refusing the whole run is the point: the listing is not trustworthy, so // nothing in it should be acted on, not even the parts that look fine. this.logger.warn(`link ${link.id}: mass-delete guard tripped (${plan.missingCount} of ${items.length} gone), run abandoned`); - this.recordLinkFailure(link, 'mass_delete_guard', 'partial'); + await this.recordLinkFailure(link, 'mass_delete_guard', 'partial'); return { state: 'partial', pulled: 0, pushed: 0, conflicts: 0, missing: plan.missingCount, errorCode: 'mass_delete_guard' }; } @@ -283,7 +283,7 @@ export class DocSyncService { * clean. */ const cursor = state === 'ok' && !wroteUpstream ? listing.data.cursor : null; - this.recordLinkSuccess(link, cursor, state, softFailure ?? null); + await this.recordLinkSuccess(link, cursor, state, softFailure ?? null); if (pulled > 0 || pushed > 0) { this.realtime.broadcast(link.trip_id, 'docsync:changed', { linkId: link.id, pulled, pushed }); } @@ -468,17 +468,17 @@ export class DocSyncService { // when it arrives through the store instead. const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, await this.allowedTypes.get()); if (isBlockedName(name) || !allowed) { - this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' }); + await this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' }); return 'unsupported_type'; } if (remote.size !== null && remote.size > MAX_FILE_SIZE) { - this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' }); + await this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' }); return 'too_large'; } const fetched = await ctx.provider.fetch(ctx.ref, ctx.scope, remote.remoteId); if (docFailed(fetched)) { - this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code }); + await this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code }); return fetched.error.code; } @@ -506,7 +506,7 @@ export class DocSyncService { } catch (err) { await fs.promises.rm(tmpPath, { force: true }); const code: DocsyncErrorCode = err instanceof Error && err.message === 'too_large' ? 'too_large' : 'provider_error'; - this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code }); + await this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code }); return code; } @@ -531,7 +531,7 @@ export class DocSyncService { // here would read it as TREK's own and send the old name back up. if (superseded && !superseded.deleted_at && pairing?.content_sha256 === sha256) { await fs.promises.rm(tmpPath, { force: true }); - this.upsertItem(ctx.link, { + await this.upsertItem(ctx.link, { itemId, remote, state: 'synced', @@ -545,7 +545,7 @@ export class DocSyncService { await this.storage.put('files', storageKey, { tmpPath }); } catch { await fs.promises.rm(tmpPath, { force: true }); - this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' }); + await this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' }); return 'provider_error'; } @@ -568,7 +568,7 @@ export class DocSyncService { { filename: storageKey, originalname: name, size: bytes, mimetype: remote.mimeType || 'application/octet-stream' }, // Attributed to the person whose connection brought it in, which is the // only honest answer: nobody in TREK uploaded it. - this.config.getConnection(ctx.link.connection_id)?.owner_user_id ?? 0, + (await this.config.getConnection(ctx.link.connection_id))?.owner_user_id ?? 0, { place_id: superseded?.place_id ?? null, reservation_id: superseded?.reservation_id ?? null, @@ -587,7 +587,7 @@ export class DocSyncService { .run(file.id, supersededId); await this.files.softDeleteFile(supersededId as number); } - this.upsertItem(ctx.link, { + await this.upsertItem(ctx.link, { itemId, remote, state: 'synced', @@ -618,11 +618,11 @@ export class DocSyncService { // Paperless refuses anything outside its parser list with a 400. Checking // first turns a recurring hard failure into one visible row that says why. if (caps.acceptedMimeTypes && !caps.acceptedMimeTypes.includes(mime)) { - this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' }); + await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' }); return 'unsupported_type'; } if (caps.maxUploadBytes !== null && local.size > caps.maxUploadBytes) { - this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' }); + await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' }); return 'too_large'; } @@ -644,11 +644,11 @@ export class DocSyncService { const got = await this.storage.getStream('files', String(file.filename)); stream = got.stream; } catch { - this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' }); + await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' }); return 'provider_error'; } - const uid = this.existingUid(ctx.link.id, itemId) ?? newTrekDocUid(); + const uid = (await this.existingUid(ctx.link.id, itemId)) ?? newTrekDocUid(); const res = await ctx.provider.push(ctx.ref, ctx.scope, { body: stream, fileName: local.name, @@ -662,7 +662,7 @@ export class DocSyncService { }); if (docFailed(res)) { - this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid }); + await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid }); return res.error.code; } @@ -672,9 +672,9 @@ export class DocSyncService { // the unique index on (link_id, remote_id) would abort this run (and every // run after it) with a constraint error. The upload happened, so nothing is // lost; what must not happen is the pairing moving off the file that owns it. - const heldBy = this.pairingOwner(ctx.link.id, res.data.remoteId, itemId); + const heldBy = await this.pairingOwner(ctx.link.id, res.data.remoteId, itemId); if (heldBy !== null) { - this.upsertItem(ctx.link, { + await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', @@ -684,7 +684,7 @@ export class DocSyncService { return 'conflict'; } - this.upsertItem(ctx.link, { + await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'synced', @@ -718,7 +718,7 @@ export class DocSyncService { * and the only way to find out is to try again. The scheduler never calls * this: automatic retries are exactly what the limit exists to stop. */ - retryShelvedItems(linkId: number): void { + async retryShelvedItems(linkId: number): Promise { this.db.connection .prepare( `UPDATE document_sync_items @@ -730,7 +730,7 @@ export class DocSyncService { // ── State loading and writing ────────────────────────────────────────────── - private loadItems(linkId: number): SyncItemState[] { + private async loadItems(linkId: number): Promise { const rows = this.db.connection .prepare( `SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, remote_size, remote_modified_at, @@ -771,7 +771,7 @@ export class DocSyncService { * Paperless, everything else to Nextcloud": splitting a trip across bindings * would need a per-document assignment that nothing in the UI offers yet. */ - private loadLocalDocuments(link: LinkRow): LocalDocument[] { + private async loadLocalDocuments(link: LinkRow): Promise { const rows = this.db.connection .prepare( `SELECT f.id, f.original_name, f.file_size, f.mime_type, f.deleted_at @@ -797,7 +797,7 @@ export class DocSyncService { } /** The item already paired with this remote document, if it is a different one. */ - private pairingOwner(linkId: number, remoteId: string, itemId: number | null): number | null { + private async pairingOwner(linkId: number, remoteId: string, itemId: number | null): Promise { const row = this.db.connection .prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?') .get(linkId, remoteId) as { id?: number } | undefined; @@ -805,7 +805,7 @@ export class DocSyncService { return itemId !== null && Number(row.id) === Number(itemId) ? null : Number(row.id); } - private existingUid(linkId: number, itemId: number | null): string | null { + private async existingUid(linkId: number, itemId: number | null): Promise { if (itemId === null) return null; const row = this.db.connection .prepare('SELECT trek_doc_uid FROM document_sync_items WHERE id = ? AND link_id = ?') @@ -820,7 +820,7 @@ export class DocSyncService { return hash.digest('hex'); } - private upsertItem( + private async upsertItem( link: LinkRow, patch: { itemId: number | null; @@ -839,7 +839,7 @@ export class DocSyncService { pushedSha256?: string; trekDocUid?: string; }, - ): void { + ): Promise { const failed = patch.state === 'error'; /** * A failed transfer leaves the pairing describing the copy TREK holds. @@ -946,7 +946,7 @@ export class DocSyncService { ); } - private recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): void { + private async recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): Promise { this.db.connection .prepare( `UPDATE trip_document_links @@ -957,7 +957,7 @@ export class DocSyncService { .run(cursor, state, errorCode, link.id); } - private recordLinkFailure(link: LinkRow, code: string, state = 'failed'): void { + private async recordLinkFailure(link: LinkRow, code: string, state = 'failed'): Promise { const failures = link.failure_count + 1; const wait = backoffSeconds(LINK_BACKOFF_SECONDS, failures); this.db.connection @@ -998,7 +998,7 @@ export class DocSyncService { * next run to sort out rather than the owner's choice being refused. */ private async renameRemoteTo(link: LinkRow, remoteId: string, name: string, itemId: number): Promise { - const conn = this.config.getConnection(link.connection_id); + const conn = await this.config.getConnection(link.connection_id); const provider = this.registry.get(link.provider_id); if (!conn || !provider) return; const res = await provider.rename(this.config.toRef(conn), this.config.toScopeRef(link), remoteId, name); @@ -1017,7 +1017,7 @@ export class DocSyncService { .get(itemId) as Record | undefined; if (!item || item.state !== 'conflict') return false; if (tripId !== undefined && Number(item.trip_id) !== Number(tripId)) return false; - const link = this.config.getLink(Number(item.link_id)); + const link = await this.config.getLink(Number(item.link_id)); if (!link) return false; /** @@ -1087,7 +1087,7 @@ export class DocSyncService { * that vanished upstream. Deliberately not "everything not synced": a row * waiting for its turn is not a problem anyone should be shown. */ - issues(tripId: number): Array> { + async issues(tripId: number): Promise>> { return this.db.connection .prepare( `SELECT i.id, i.state, i.error_code, i.remote_name, i.remote_missing_at, f.original_name AS file_name @@ -1103,7 +1103,7 @@ export class DocSyncService { /** Per-trip view for the UI: what is synced, what needs attention. */ async status(tripId: number): Promise> { - const links = this.config.listLinks(tripId); + const links = await this.config.listLinks(tripId); const counts = this.db.connection .prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state') .all(tripId) as Array<{ state: string; n: number }>; @@ -1146,7 +1146,7 @@ export class DocSyncService { for (const l of links) { const row = byLink.get(l.id); linkCards.push({ - ...this.config.publicLink(l, null), + ...(await this.config.publicLink(l, null)), // Paused rather than failed, so the card can say why nothing moves // without the binding's own state being touched. providerOff: await this.isSwitchedOff(l), diff --git a/server/src/nest/doc-sync/document-provider.ts b/server/src/nest/doc-sync/document-provider.ts index 3a79b1f28..ed5df6385 100644 --- a/server/src/nest/doc-sync/document-provider.ts +++ b/server/src/nest/doc-sync/document-provider.ts @@ -88,7 +88,7 @@ export interface DocumentConnectionRef { * stored but never changes it: the values on screen may be for another * account, or never be saved at all. */ - saveSecret?: (key: string, value: string | null) => void; + saveSecret?: (key: string, value: string | null) => Promise; } /** The container at the provider that one trip is bound to. */ diff --git a/server/src/nest/doc-sync/providers/synology-drive.client.ts b/server/src/nest/doc-sync/providers/synology-drive.client.ts index cf84f361c..dcb39ea54 100644 --- a/server/src/nest/doc-sync/providers/synology-drive.client.ts +++ b/server/src/nest/doc-sync/providers/synology-drive.client.ts @@ -228,7 +228,7 @@ export interface SynologyDriveCreds { * connection passes one; a probe of form values uses the stored token without * being able to change it. */ - saveDeviceToken?: (stored: string | null) => void; + saveDeviceToken?: (stored: string | null) => Promise; allowInsecureTls: boolean; } @@ -554,7 +554,7 @@ export class SynologyDriveClient { * anything is written, so a change is written once and not on every call of * the run that made it. */ - private deviceSlot(creds: SynologyDriveCreds): DeviceTokenSlot | null { + private async deviceSlot(creds: SynologyDriveCreds): Promise { if (creds.connectionId <= 0) return null; const key = `${creds.connectionId}\u0000${creds.connectionCreatedAt}`; const account = this.accountDigest(creds); @@ -563,24 +563,24 @@ export class SynologyDriveClient { slot = { account, token: deviceTokenFor(creds.storedDeviceToken, account) }; this.deviceTokens.set(key, slot); } - this.persist(creds, slot); + await this.persist(creds, slot); return slot; } - private persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): void { + private async persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): Promise { if (!creds.saveDeviceToken) return; if (slot.stored === undefined) slot.stored = creds.storedDeviceToken ?? null; const wanted = slot.token === null ? null : storedDeviceToken(slot.account, slot.token); if (wanted === slot.stored) return; - creds.saveDeviceToken(wanted); + await creds.saveDeviceToken(wanted); slot.stored = wanted; } - private setDeviceToken(creds: SynologyDriveCreds, token: string | null): void { - const slot = this.deviceSlot(creds); + private async setDeviceToken(creds: SynologyDriveCreds, token: string | null): Promise { + const slot = await this.deviceSlot(creds); if (!slot || slot.token === token) return; slot.token = token; - this.persist(creds, slot); + await this.persist(creds, slot); } private async request( @@ -691,8 +691,8 @@ export class SynologyDriveClient { private async session(creds: SynologyDriveCreds): Promise { const cached = this.sessions.get(this.sessionKey(creds)); if (cached && Date.now() - cached.createdAt < SESSION_MAX_AGE_MS) { - const slot = this.deviceSlot(creds); - if (slot && slot.token === null && cached.deviceId) this.setDeviceToken(creds, cached.deviceId); + const slot = await this.deviceSlot(creds); + if (slot && slot.token === null && cached.deviceId) await this.setDeviceToken(creds, cached.deviceId); return cached; } return await this.login(creds); @@ -736,7 +736,7 @@ export class SynologyDriveClient { throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id'); } const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId; - if (issuedDeviceId) this.setDeviceToken(creds, issuedDeviceId); + if (issuedDeviceId) await this.setDeviceToken(creds, issuedDeviceId); const session: SynoSession = { sid, synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null, @@ -761,7 +761,7 @@ export class SynologyDriveClient { * is what the lockout records. */ private async authenticate(creds: SynologyDriveCreds): Promise<{ data: unknown; deviceId: string | null }> { - const deviceId = this.deviceSlot(creds)?.token ?? null; + const deviceId = (await this.deviceSlot(creds))?.token ?? null; try { return { data: await this.call(creds, this.loginParams(creds, deviceId), { cgi: AUTH_CGI }), deviceId }; } catch (error: unknown) { @@ -769,7 +769,7 @@ export class SynologyDriveClient { throw error; } // Unless another login replaced it while this one was out. - if (this.deviceSlot(creds)?.token === deviceId) this.setDeviceToken(creds, null); + if ((await this.deviceSlot(creds))?.token === deviceId) await this.setDeviceToken(creds, null); if (!creds.otpCode) throw error; return { data: await this.call(creds, this.loginParams(creds, null), { cgi: AUTH_CGI }), deviceId: null }; } diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 04a06cf12..2765828cc 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -417,7 +417,7 @@ export class TripsService { `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId); if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) - this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); + await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); if (regenerate) { await this.uow.transactional(async () => { diff --git a/server/src/nest/vacay/vacay.controller.ts b/server/src/nest/vacay/vacay.controller.ts index 40325877b..f024279e3 100644 --- a/server/src/nest/vacay/vacay.controller.ts +++ b/server/src/nest/vacay/vacay.controller.ts @@ -60,32 +60,32 @@ export class VacayController { @Put('plan') async updatePlan(@CurrentUser() user: User, @Body() body: VacayUpdatePlanDto, @Headers('x-socket-id') socketId?: string) { - const planId = this.vacay.getActivePlanId(user.id); + const planId = await this.vacay.getActivePlanId(user.id); return this.vacay.updatePlan(planId, body, socketId); } @Post('plan/holiday-calendars') @HttpCode(200) - addHolidayCalendar( + async addHolidayCalendar( @CurrentUser() user: User, @Body() body: VacayAddHolidayCalendarDto, @Headers('x-socket-id') socketId?: string, ) { - const planId = this.vacay.getActivePlanId(user.id); - const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type); + const planId = await this.vacay.getActivePlanId(user.id); + const calendar = await this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type); return { calendar }; } @Put('plan/holiday-calendars/:id') - updateHolidayCalendar( + async updateHolidayCalendar( @CurrentUser() user: User, @Param('id') idParam: string, @Body() body: VacayUpdateHolidayCalendarDto, @Headers('x-socket-id') socketId?: string, ) { const id = Number.parseInt(idParam); - const planId = this.vacay.getActivePlanId(user.id); - const calendar = this.vacay.updateHolidayCalendar(id, planId, body, socketId); + const planId = await this.vacay.getActivePlanId(user.id); + const calendar = await this.vacay.updateHolidayCalendar(id, planId, body, socketId); if (!calendar) { throw new HttpException({ error: 'Calendar not found' }, 404); } @@ -93,38 +93,38 @@ export class VacayController { } @Delete('plan/holiday-calendars/:id') - deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) { + async deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) { const id = Number.parseInt(idParam); - const planId = this.vacay.getActivePlanId(user.id); - if (!this.vacay.deleteHolidayCalendar(id, planId, socketId)) { + const planId = await this.vacay.getActivePlanId(user.id); + if (!(await this.vacay.deleteHolidayCalendar(id, planId, socketId))) { throw new HttpException({ error: 'Calendar not found' }, 404); } return { success: true }; } @Put('color') - setColor( + async setColor( @CurrentUser() user: User, @Body() body: VacaySetColorDto, @Headers('x-socket-id') socketId?: string, ) { - const planId = this.vacay.getActivePlanId(user.id); + const planId = await this.vacay.getActivePlanId(user.id); const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id; - if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) { + if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === userId)) { throw new HttpException({ error: 'User not in plan' }, 403); } - this.vacay.setUserColor(userId, planId, body.color, socketId); + await this.vacay.setUserColor(userId, planId, body.color, socketId); return { success: true }; } @Post('invite') @HttpCode(200) - invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) { + async invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) { if (!body.user_id) { throw new HttpException({ error: 'user_id required' }, 400); } - const plan = this.vacay.getActivePlan(user.id); - const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number); + const plan = await this.vacay.getActivePlan(user.id); + const result = await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -133,8 +133,8 @@ export class VacayController { @Post('invite/accept') @HttpCode(200) - acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) { - const result = this.vacay.acceptInvite(user.id, body.plan_id as number, socketId); + async acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.vacay.acceptInvite(user.id, body.plan_id as number, socketId); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -143,71 +143,71 @@ export class VacayController { @Post('invite/decline') @HttpCode(200) - declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) { - this.vacay.declineInvite(user.id, body.plan_id as number, socketId); + async declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) { + await this.vacay.declineInvite(user.id, body.plan_id as number, socketId); return { success: true }; } @Post('invite/cancel') @HttpCode(200) - cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) { - const plan = this.vacay.getActivePlan(user.id); - this.vacay.cancelInvite(plan.id, body.user_id as number); + async cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) { + const plan = await this.vacay.getActivePlan(user.id); + await this.vacay.cancelInvite(plan.id, body.user_id as number); return { success: true }; } @Post('dissolve') @HttpCode(200) - dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) { - this.vacay.dissolvePlan(user.id, socketId); + async dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) { + await this.vacay.dissolvePlan(user.id, socketId); return { success: true }; } @Get('available-users') - availableUsers(@CurrentUser() user: User) { - const planId = this.vacay.getActivePlanId(user.id); - return { users: this.vacay.getAvailableUsers(user.id, planId) }; + async availableUsers(@CurrentUser() user: User) { + const planId = await this.vacay.getActivePlanId(user.id); + return { users: await this.vacay.getAvailableUsers(user.id, planId) }; } @Get('years') - years(@CurrentUser() user: User) { - const planId = this.vacay.getActivePlanId(user.id); - return { years: this.vacay.listYears(planId) }; + async years(@CurrentUser() user: User) { + const planId = await this.vacay.getActivePlanId(user.id); + return { years: await this.vacay.listYears(planId) }; } @Post('years') @HttpCode(200) - addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) { + async addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) { if (!body.year) { throw new HttpException({ error: 'Year required' }, 400); } - const planId = this.vacay.getActivePlanId(user.id); - return { years: this.vacay.addYear(planId, body.year as number, socketId) }; + const planId = await this.vacay.getActivePlanId(user.id); + return { years: await this.vacay.addYear(planId, body.year as number, socketId) }; } @Delete('years/:year') - deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) { + async deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) { const year = Number.parseInt(yearParam); - const planId = this.vacay.getActivePlanId(user.id); - return { years: this.vacay.deleteYear(planId, year, socketId) }; + const planId = await this.vacay.getActivePlanId(user.id); + return { years: await this.vacay.deleteYear(planId, year, socketId) }; } @Get('year-settings') - yearSettings(@CurrentUser() user: User) { - return { settings: this.vacay.getYearSettings(user.id) }; + async yearSettings(@CurrentUser() user: User) { + return { settings: await this.vacay.getYearSettings(user.id) }; } @Put('year-settings') - updateYearSettings( + async updateYearSettings( @CurrentUser() user: User, @Body() body: VacayYearSettingsDto, ) { - return { settings: this.vacay.updateYearSettings(user.id, body) }; + return { settings: await this.vacay.updateYearSettings(user.id, body) }; } @Get('entries/:year') - entries(@CurrentUser() user: User, @Param('year') year: string) { - const planId = this.vacay.getActivePlanId(user.id); + async entries(@CurrentUser() user: User, @Param('year') year: string) { + const planId = await this.vacay.getActivePlanId(user.id); // Entries load over the caller's leave-year window (#737), so a shifted year // returns both calendar halves the grid renders. return this.vacay.getEntries(planId, year, user.id); @@ -215,21 +215,21 @@ export class VacayController { @Post('entries/toggle') @HttpCode(200) - toggleEntry( + async toggleEntry( @CurrentUser() user: User, @Body() body: VacayToggleEntryDto, @Headers('x-socket-id') socketId?: string, ) { - const planId = this.vacay.getActivePlanId(user.id); + const planId = await this.vacay.getActivePlanId(user.id); let userId = user.id; if (body.target_user_id && Number.parseInt(String(body.target_user_id)) !== user.id) { const tid = Number.parseInt(String(body.target_user_id)); - if (!this.vacay.getPlanUsers(planId).find((u) => u.id === tid)) { + if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === tid)) { throw new HttpException({ error: 'User not in plan' }, 403); } userId = tid; } - const result = this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId); + const result = await this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId); if (result.error === 'weekend_blocked') { throw new HttpException({ error: 'Weekend days are blocked on this plan' }, 400); } @@ -238,36 +238,36 @@ export class VacayController { @Post('entries/company-holiday') @HttpCode(200) - companyHoliday( + async companyHoliday( @CurrentUser() user: User, @Body() body: VacayCompanyHolidayDto, @Headers('x-socket-id') socketId?: string, ) { - const planId = this.vacay.getActivePlanId(user.id); + const planId = await this.vacay.getActivePlanId(user.id); return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId); } @Get('stats/:year') - stats(@CurrentUser() user: User, @Param('year') yearParam: string) { + async stats(@CurrentUser() user: User, @Param('year') yearParam: string) { const year = Number.parseInt(yearParam); - const planId = this.vacay.getActivePlanId(user.id); - return { stats: this.vacay.getStats(planId, year) }; + const planId = await this.vacay.getActivePlanId(user.id); + return { stats: await this.vacay.getStats(planId, year) }; } @Put('stats/:year') - updateStats( + async updateStats( @CurrentUser() user: User, @Param('year') yearParam: string, @Body() body: VacayUpdateStatsDto, @Headers('x-socket-id') socketId?: string, ) { const year = Number.parseInt(yearParam); - const planId = this.vacay.getActivePlanId(user.id); + const planId = await this.vacay.getActivePlanId(user.id); const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id; - if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) { + if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === userId)) { throw new HttpException({ error: 'User not in plan' }, 403); } - this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId); + await this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId); return { success: true }; } @@ -278,7 +278,7 @@ export class VacayController { @Post('shares') @HttpCode(200) - share( + async share( @CurrentUser() user: User, @Body() body: VacayShareDto, @Headers('x-socket-id') socketId?: string, @@ -286,7 +286,7 @@ export class VacayController { if (!body.user_id) { throw new HttpException({ error: 'user_id required' }, 400); } - const result = this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId); + const result = await this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -294,31 +294,31 @@ export class VacayController { } @Get('shares/available-users') - shareAvailableUsers(@CurrentUser() user: User) { - return { users: this.vacay.getShareAvailableUsers(user.id) }; + async shareAvailableUsers(@CurrentUser() user: User) { + return { users: await this.vacay.getShareAvailableUsers(user.id) }; } @Get('shares/calendars/:year') - sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) { - return { calendars: this.vacay.getSharedCalendars(user.id, year) }; + async sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) { + return { calendars: await this.vacay.getSharedCalendars(user.id, year) }; } @Put('shares/:id') - updateShare( + async updateShare( @CurrentUser() user: User, @Param('id') idParam: string, @Body() body: VacayShareUpdateDto, @Headers('x-socket-id') socketId?: string, ) { - if (!this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId)) { + if (!(await this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId))) { throw new HttpException({ error: 'Share not found' }, 404); } return { success: true }; } @Delete('shares/:id') - deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) { - if (!this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId)) { + async deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId))) { throw new HttpException({ error: 'Share not found' }, 404); } return { success: true }; diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index 5904d286c..05d3ac630 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -92,7 +92,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getVacayPlan(_args: Record, ctx: McpContext) { - const plan = this.vacay.getPlanData(ctx.userId); + const plan = await this.vacay.getPlanData(ctx.userId); return ok({ plan }); } @@ -105,7 +105,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getVacayYearSettings(_args: Record, ctx: McpContext) { - return ok({ settings: this.vacay.getYearSettings(ctx.userId) }); + return ok({ settings: await this.vacay.getYearSettings(ctx.userId) }); } @Tool({ @@ -128,7 +128,7 @@ export class VacayMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); + const planId = await this.vacay.getActivePlanId(ctx.userId); // updatePlan already returns the fully-hydrated { plan }; surface it so the // AI consumer sees the updated plan, matching get_vacay_plan. const result = await this.vacay.updatePlan(planId, { @@ -150,8 +150,8 @@ export class VacayMcp { }) async setVacayColor({ color }: { color: string }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - this.vacay.setUserColor(ctx.userId, planId, color, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + await this.vacay.setUserColor(ctx.userId, planId, color, undefined); // Echo the persisted color (mirrors the service default) so the AI consumer sees what was set. return ok({ success: true, color: color || '#6366f1' }); } @@ -165,8 +165,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getAvailableVacayUsers(_args: Record, ctx: McpContext) { - const planId = this.vacay.getActivePlanId(ctx.userId); - const users = this.vacay.getAvailableUsers(ctx.userId, planId); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const users = await this.vacay.getAvailableUsers(ctx.userId, planId); return ok({ users }); } @@ -182,10 +182,10 @@ export class VacayMcp { }) async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); + const planId = await this.vacay.getActivePlanId(ctx.userId); const me = await this.auth.getCurrentUser(ctx.userId); if (!me) return errorResult('User not found.'); - const result = this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId); + const result = await this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId); if (result.error) return errorResult(result.error); return ok({ success: true }); } @@ -202,7 +202,7 @@ export class VacayMcp { }) async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const result = this.vacay.acceptInvite(ctx.userId, planId, undefined); + const result = await this.vacay.acceptInvite(ctx.userId, planId, undefined); if (result.error) return errorResult(result.error); return ok({ success: true }); } @@ -219,7 +219,7 @@ export class VacayMcp { }) async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - this.vacay.declineInvite(ctx.userId, planId, undefined); + await this.vacay.declineInvite(ctx.userId, planId, undefined); return ok({ success: true }); } @@ -235,8 +235,8 @@ export class VacayMcp { }) async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - this.vacay.cancelInvite(planId, targetUserId); + const planId = await this.vacay.getActivePlanId(ctx.userId); + await this.vacay.cancelInvite(planId, targetUserId); return ok({ success: true }); } @@ -250,7 +250,7 @@ export class VacayMcp { }) async dissolveVacayPlan(_args: Record, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - this.vacay.dissolvePlan(ctx.userId, undefined); + await this.vacay.dissolvePlan(ctx.userId, undefined); return ok({ success: true }); } @@ -263,8 +263,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async listVacayYears(_args: Record, ctx: McpContext) { - const planId = this.vacay.getActivePlanId(ctx.userId); - const years = this.vacay.listYears(planId); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const years = await this.vacay.listYears(planId); return ok({ years }); } @@ -280,8 +280,8 @@ export class VacayMcp { }) async addVacayYear({ year }: { year: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - const years = this.vacay.addYear(planId, year, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const years = await this.vacay.addYear(planId, year, undefined); return ok({ years }); } @@ -297,8 +297,8 @@ export class VacayMcp { }) async deleteVacayYear({ year }: { year: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - const years = this.vacay.deleteYear(planId, year, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const years = await this.vacay.deleteYear(planId, year, undefined); return ok({ years }); } @@ -313,8 +313,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getVacayEntries({ year }: { year: number }, ctx: McpContext) { - const planId = this.vacay.getActivePlanId(ctx.userId); - const entries = this.vacay.getEntries(planId, String(year), ctx.userId); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const entries = await this.vacay.getEntries(planId, String(year), ctx.userId); return ok({ entries }); } @@ -336,17 +336,17 @@ export class VacayMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); + const planId = await this.vacay.getActivePlanId(ctx.userId); let userId = ctx.userId; if (targetUserId !== undefined && targetUserId !== ctx.userId) { // Same gate as POST /entries/toggle: booking for someone else reaches only // the members of the caller's own plan, and nobody outside it. - if (!this.vacay.getPlanUsers(planId).find((u) => u.id === targetUserId)) { + if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === targetUserId)) { return errorResult('User not in plan'); } userId = targetUserId; } - const result = this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined); + const result = await this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined); if (result.error) return errorResult(result.error); return ok(result); } @@ -364,8 +364,8 @@ export class VacayMcp { }) async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const result = await this.vacay.toggleCompanyHoliday(planId, date, note, undefined); return ok(result); } @@ -380,8 +380,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getVacayStats({ year }: { year: number }, ctx: McpContext) { - const planId = this.vacay.getActivePlanId(ctx.userId); - const stats = this.vacay.getStats(planId, year); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const stats = await this.vacay.getStats(planId, year); return ok({ stats }); } @@ -398,8 +398,8 @@ export class VacayMcp { }) async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + await this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined); return ok({ success: true }); } @@ -422,10 +422,10 @@ export class VacayMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); + const planId = await this.vacay.getActivePlanId(ctx.userId); // An omitted type stays undefined so the service default (and the default // colour that goes with it) applies, exactly as on the REST route. - const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type); + const calendar = await this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type); return ok({ calendar }); } @@ -446,8 +446,8 @@ export class VacayMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - const cal = this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const cal = await this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined); if (!cal) return errorResult('Holiday calendar not found.'); return ok({ calendar: cal }); } @@ -464,8 +464,8 @@ export class VacayMcp { }) async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const planId = this.vacay.getActivePlanId(ctx.userId); - this.vacay.deleteHolidayCalendar(calendarId, planId, undefined); + const planId = await this.vacay.getActivePlanId(ctx.userId); + await this.vacay.deleteHolidayCalendar(calendarId, planId, undefined); return ok({ success: true }); } @@ -547,7 +547,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async listVacayShares(_args: Record, ctx: McpContext) { - return ok(this.vacay.listShares(ctx.userId)); + return ok(await this.vacay.listShares(ctx.userId)); } @Tool({ @@ -559,7 +559,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getShareableVacayUsers(_args: Record, ctx: McpContext) { - return ok({ users: this.vacay.getShareAvailableUsers(ctx.userId) }); + return ok({ users: await this.vacay.getShareAvailableUsers(ctx.userId) }); } @Tool({ @@ -576,7 +576,7 @@ export class VacayMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const me = await this.auth.getCurrentUser(ctx.userId); if (!me) return errorResult('User not found.'); - const result = this.vacay.shareCalendar(ctx.userId, me.email, targetUserId); + const result = await this.vacay.shareCalendar(ctx.userId, me.email, targetUserId); if (result.error) return errorResult(result.error); return ok({ success: true }); } @@ -593,7 +593,7 @@ export class VacayMcp { }) async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) { + if (!(await this.vacay.removeShare(shareId, ctx.userId, undefined))) { return errorResult('Share not found.'); } return ok({ success: true }); @@ -610,7 +610,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async getSharedVacayCalendars({ year }: { year: number }, ctx: McpContext) { - return ok({ calendars: this.vacay.getSharedCalendars(ctx.userId, String(year)) }); + return ok({ calendars: await this.vacay.getSharedCalendars(ctx.userId, String(year)) }); } // ------------------------------------------------------------------------- @@ -626,7 +626,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async vacayPlanResource(uri: URL, ctx: McpContext) { - const plan = this.vacay.getPlanData(ctx.userId); + const plan = await this.vacay.getPlanData(ctx.userId); return { contents: [{ uri: uri.href, @@ -645,8 +645,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async vacayEntriesResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) { - const planId = this.vacay.getActivePlanId(ctx.userId); - const entries = this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId); + const planId = await this.vacay.getActivePlanId(ctx.userId); + const entries = await this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId); return { contents: [{ uri: uri.href, @@ -665,7 +665,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'read' }, }) async vacayHolidaysResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) { - const plan = this.vacay.getActivePlan(ctx.userId); + const plan = await this.vacay.getActivePlan(ctx.userId); const json = (data: unknown) => ({ contents: [{ uri: uri.href, diff --git a/server/src/nest/vacay/vacay.rpc.ts b/server/src/nest/vacay/vacay.rpc.ts index c7266ce78..223e9dc31 100644 --- a/server/src/nest/vacay/vacay.rpc.ts +++ b/server/src/nest/vacay/vacay.rpc.ts @@ -31,7 +31,7 @@ export class VacayRpc { const userId = this.requireVacayUser(ctx, 'writes'); const date = this.dateStr(params.date); await this.requireVacayAddon(); - return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); + return await this.vacay.toggleEntry(userId, await this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); } @PluginMethod('vacay.toggleCompanyHoliday', { permission: 'db:write:vacay' }) @@ -40,7 +40,7 @@ export class VacayRpc { const date = this.dateStr(params.date); const note = typeof params.note === 'string' ? params.note.slice(0, 256) : undefined; await this.requireVacayAddon(); - return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined); + return await this.vacay.toggleCompanyHoliday(await this.vacay.getActivePlanId(userId), date, note, undefined); } private requireVacayUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number { diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 78b1fa076..35f14bba1 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -2,6 +2,7 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { manualSchoolRegionId } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { NotificationsService } from '../notifications/notifications.service'; import { discardBody, readCappedJson } from '../../utils/cappedFetch'; @@ -187,6 +188,7 @@ export class VacayService { private readonly db: DatabaseService, private readonly realtime: RealtimeService, private readonly notifications: NotificationsService, + private readonly uow: UnitOfWork, ) {} private readonly holidayCache = new Map(); @@ -200,12 +202,12 @@ export class VacayService { * half day (#552) counts as 0.5 and a full day as 1. Entries predating the * feature have fraction = 1, so this matches the old COUNT(*) for them. */ - private usedDays(userId: number, planId: number, year: number): number { + private async usedDays(userId: number, planId: number, year: number): Promise { // Comp/Flex days (#1074) are free — kind='comp' contributes 0 to the entitlement, // vacation days contribute their fraction. Entries predating the column are // 'vacation' by default. The window (#737) is the user's leave-year period; for // 'calendar' it is Jan 1 – Dec 31, byte-identical to the old date-prefix match. - const { start, end } = this.resolveYearWindow(userId, year); + const { start, end } = await this.resolveYearWindow(userId, year); const row = this.db.get<{ used: number }>( "SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ?", userId, planId, start, end @@ -214,8 +216,8 @@ export class VacayService { } /** Comp/Flex days (#1074) used in a user's leave-year period — SUM of fractions for kind='comp'. */ - private compUsedDays(userId: number, planId: number, year: number): number { - const { start, end } = this.resolveYearWindow(userId, year); + private async compUsedDays(userId: number, planId: number, year: number): Promise { + const { start, end } = await this.resolveYearWindow(userId, year); const row = this.db.get<{ used: number }>( "SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'", userId, planId, start, end @@ -227,13 +229,13 @@ export class VacayService { // Configurable vacation year (#737) // ------------------------------------------------------------------------- - getUserYearSettings(userId: number): VacayUserSettings | undefined { + async getUserYearSettings(userId: number): Promise { return this.db.get('SELECT * FROM vacay_user_settings WHERE user_id = ?', userId); } /** A user's leave-year settings with the calendar defaults filled in (#737). */ - getYearSettings(userId: number): VacayUserSettings { - return this.getUserYearSettings(userId) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null }; + async getYearSettings(userId: number): Promise { + return (await this.getUserYearSettings(userId)) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null }; } /** @@ -244,8 +246,8 @@ export class VacayService { * end exclusive. Because periods are consecutive, `year-1` is always the window that * ends where `year`'s begins, so the carry-over chains stay valid unchanged. */ - resolveYearWindow(userId: number, year: number): { start: string; end: string } { - const s = this.getUserYearSettings(userId); + async resolveYearWindow(userId: number, year: number): Promise<{ start: string; end: string }> { + const s = await this.getUserYearSettings(userId); // 'anniversary' before a hire date is entered has nothing to anchor to, so it // reads as the calendar default rather than borrowing a month left behind by a // previous 'fiscal' setting — the client mirror resolves it the same way. @@ -272,13 +274,13 @@ export class VacayService { * (#737). Without a viewer — MCP resources, which are plan-scoped — it stays the * plain calendar year, which is exactly what a 'calendar' user resolves to. */ - private viewerWindow(year: number | string, viewerId?: number): { start: string; end: string } { + private async viewerWindow(year: number | string, viewerId?: number): Promise<{ start: string; end: string }> { const y = typeof year === 'number' ? year : Number.parseInt(year, 10); // A non-numeric year matched nothing under the old date prefix; an empty range // matches nothing either, so a bad parameter still yields an empty result. if (!Number.isFinite(y)) return { start: '', end: '' }; if (viewerId == null) return { start: `${y}-01-01`, end: `${y + 1}-01-01` }; - return this.resolveYearWindow(viewerId, y); + return await this.resolveYearWindow(viewerId, y); } /** @@ -294,8 +296,8 @@ export class VacayService { * only the grid's edges are approximate. Rendering a 13th month card would fix it * and was deliberately not taken. */ - private viewerGridWindow(year: number | string, viewerId?: number): { start: string; end: string } { - const w = this.viewerWindow(year, viewerId); + private async viewerGridWindow(year: number | string, viewerId?: number): Promise<{ start: string; end: string }> { + const w = await this.viewerWindow(year, viewerId); if (!w.start) return w; return { start: `${w.start.slice(0, 7)}-01`, end: `${w.end.slice(0, 7)}-01` }; } @@ -305,17 +307,17 @@ export class VacayService { * window that starts later in the year, today still belongs to the period named * after the previous calendar year — 'calendar' users always get today's year. */ - currentPeriodYear(userId: number, date = new Date()): number { + async currentPeriodYear(userId: number, date = new Date()): Promise { const y = date.getFullYear(); const iso = `${y}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; - return iso < this.resolveYearWindow(userId, y).start ? y - 1 : y; + return iso < (await this.resolveYearWindow(userId, y)).start ? y - 1 : y; } /** Upsert a user's leave-year settings (#737). */ - updateYearSettings( + async updateYearSettings( userId: number, data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown }, - ): VacayUserSettings { + ): Promise { const type = normalizeYearType(data.year_type); const month = Math.min(12, Math.max(1, Number.parseInt(String(data.year_start_month ?? 1), 10) || 1)); const day = Math.min(31, Math.max(1, Number.parseInt(String(data.year_start_day ?? 1), 10) || 1)); @@ -326,21 +328,21 @@ export class VacayService { ON CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, year_start_month = excluded.year_start_month, year_start_day = excluded.year_start_day, hire_date = excluded.hire_date `, userId, type, month, day, hire); - return this.getUserYearSettings(userId)!; + return (await this.getUserYearSettings(userId))!; } // ------------------------------------------------------------------------- // Plan management // ------------------------------------------------------------------------- - getOwnPlan(userId: number): VacayPlan { + async getOwnPlan(userId: number): Promise { let plan = this.db.get('SELECT * FROM vacay_plans WHERE owner_id = ?', userId); if (!plan) { this.db.run('INSERT INTO vacay_plans (owner_id) VALUES (?)', userId); plan = this.db.get('SELECT * FROM vacay_plans WHERE owner_id = ?', userId)!; // Seed the period today falls into — with a shifted leave year (#737) that is // not necessarily the current calendar year. - const yr = this.currentPeriodYear(userId); + const yr = await this.currentPeriodYear(userId); this.db.run('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)', plan.id, yr); this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)', userId, plan.id, yr); this.db.run('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)', userId, plan.id, '#6366f1'); @@ -348,26 +350,26 @@ export class VacayService { return plan; } - getActivePlan(userId: number): VacayPlan { + async getActivePlan(userId: number): Promise { const membership = this.db.get<{ plan_id: number }>(` SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' `, userId); if (membership) { return this.db.get('SELECT * FROM vacay_plans WHERE id = ?', membership.plan_id)!; } - return this.getOwnPlan(userId); + return await this.getOwnPlan(userId); } - getActivePlanId(userId: number): number { - return this.getActivePlan(userId).id; + async getActivePlanId(userId: number): Promise { + return (await this.getActivePlan(userId)).id; } - shiftOwnerEntriesForTripWindow( + async shiftOwnerEntriesForTripWindow( ownerId: number, oldStart: string, oldEnd: string, newStart: string - ): void { + ): Promise { const row = this.db.get<{ days: number }>( 'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days', newStart, oldStart @@ -375,7 +377,7 @@ export class VacayService { const offset = row?.days ?? 0; if (offset === 0) return; - const plan = this.getOwnPlan(ownerId); + const plan = await this.getOwnPlan(ownerId); this.db.run( `UPDATE OR IGNORE vacay_entries @@ -387,7 +389,7 @@ export class VacayService { ); } - getPlanUsers(planId: number): VacayUser[] { + async getPlanUsers(planId: number): Promise { const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); if (!plan) return []; const owner = this.db.get('SELECT id, username, email FROM users WHERE id = ?', plan.owner_id)!; @@ -403,11 +405,11 @@ export class VacayService { // WebSocket notifications // ------------------------------------------------------------------------- - notifyPlanUsers( + async notifyPlanUsers( planId: number, excludeSid: string | undefined, event: 'vacay:update' | 'vacay:settings' | 'vacay:accepted' | 'vacay:declined' = 'vacay:update', - ): void { + ): Promise { try { const plan = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId); if (!plan) return; @@ -420,12 +422,12 @@ export class VacayService { // union proves invite/cancelled never reach this method — their senders // call broadcastToUser directly — so only declined needs excluding here.) if (event !== 'vacay:declined') { - this.notifyShareViewers(userIds, excludeSid); + await this.notifyShareViewers(userIds, excludeSid); } } catch { /* websocket not available */ } } - notifyShareViewers(ownerIds: number[], excludeSid?: string): void { + async notifyShareViewers(ownerIds: number[], excludeSid?: string): Promise { if (ownerIds.length === 0) return; try { const rows = this.db.all<{ user_id: number }>( @@ -449,11 +451,11 @@ export class VacayService { // A shifted leave year (#737) runs into the next calendar year, so collect the // calendar years the members' windows actually touch — not just the period ids. // With everyone on 'calendar' this is the same set as before. - const members = this.getPlanUsers(planId); + const members = await this.getPlanUsers(planId); const calendarYears = new Set(); for (const { year } of years) { calendarYears.add(year); - for (const m of members) calendarYears.add(windowEndYear(this.resolveYearWindow(m.id, year).end)); + for (const m of members) calendarYears.add(windowEndYear((await this.resolveYearWindow(m.id, year)).end)); } for (const cal of calendars) { const country = cal.region.split('-')[0]; @@ -537,14 +539,14 @@ export class VacayService { if (carry_over_enabled === true) { // The chained per-year/per-user recompute is atomic — a failure mid-chain // would otherwise leave later years carrying stale balances. - this.db.transaction(() => { + await this.uow.transactional(async () => { const years = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId); - const users = this.getPlanUsers(planId); + const users = await this.getPlanUsers(planId); for (let i = 0; i < years.length - 1; i++) { const yr = years[i].year; const nextYr = years[i + 1].year; for (const u of users) { - const used = this.usedDays(u.id, planId, yr); + const used = await this.usedDays(u.id, planId, yr); const config = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, yr); const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0); const carry = Math.max(0, total - used); @@ -557,7 +559,7 @@ export class VacayService { }); } - this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + await this.notifyPlanUsers(planId, socketId, 'vacay:settings'); const updated = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId)!; const updatedCalendars = this.db.all('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id', planId); @@ -578,26 +580,26 @@ export class VacayService { // Holiday calendars CRUD // ------------------------------------------------------------------------- - addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') { - this.validateManualRegion(region, type); + async addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') { + await this.validateManualRegion(region, type); const result = this.db.run( 'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)', planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0 ); const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ?', result.lastInsertRowid)!; - this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + await this.notifyPlanUsers(planId, socketId, 'vacay:settings'); return cal; } - updateHolidayCalendar( + async updateHolidayCalendar( calId: number, planId: number, body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' }, socketId: string | undefined, - ): VacayHolidayCalendar | null { + ): Promise { const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId); if (!cal) return null; - this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type); + await this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type); const { region, label, color, sort_order, type } = body; const updates: string[] = []; const params: (string | number | null)[] = []; @@ -611,19 +613,19 @@ export class VacayService { this.db.run(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`, ...params); } const updated = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ?', calId)!; - this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + await this.notifyPlanUsers(planId, socketId, 'vacay:settings'); return updated; } - deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean { + async deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): Promise { const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId); if (!cal) return false; this.db.run('DELETE FROM vacay_holiday_calendars WHERE id = ?', calId); - this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + await this.notifyPlanUsers(planId, socketId, 'vacay:settings'); return true; } - private validateManualRegion(code: string, type: string) { + private async validateManualRegion(code: string, type: string) { if (!code.includes('-MANUAL-')) return; const id = manualSchoolRegionId(code); if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) { @@ -635,19 +637,19 @@ export class VacayService { // User colors // ------------------------------------------------------------------------- - setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void { + async setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): Promise { this.db.run(` INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color `, userId, planId, color || '#6366f1'); - this.notifyPlanUsers(planId, socketId, 'vacay:update'); + await this.notifyPlanUsers(planId, socketId, 'vacay:update'); } // ------------------------------------------------------------------------- // Invitations // ------------------------------------------------------------------------- - sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } { + async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise<{ error?: string; status?: number }> { if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; // The picker no longer offers guests, but the id arrives from the client, so the @@ -684,10 +686,10 @@ export class VacayService { return {}; } - acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } { + async acceptInvite(userId: number, planId: number, socketId: string | undefined): Promise<{ error?: string; status?: number }> { // The accept flow is a multi-statement write (status flip + entry/year/color // migration + seeding) — atomic, so a failure can't leave the member half-fused. - const result = this.db.transaction((): { error?: string; status?: number } => { + const result = await this.uow.transactional(async (): Promise<{ error?: string; status?: number }> => { const invite = this.db.get("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId); if (!invite) return { error: 'No pending invite', status: 404 }; @@ -731,16 +733,16 @@ export class VacayService { // Only announce a fusion that actually happened — the transaction returns the // refusal for an invite that was already gone. - if (!result.error) this.notifyPlanUsers(planId, socketId, 'vacay:accepted'); + if (!result.error) await this.notifyPlanUsers(planId, socketId, 'vacay:accepted'); return result; } - declineInvite(userId: number, planId: number, socketId: string | undefined): void { + async declineInvite(userId: number, planId: number, socketId: string | undefined): Promise { this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId); - this.notifyPlanUsers(planId, socketId, 'vacay:declined'); + await this.notifyPlanUsers(planId, socketId, 'vacay:declined'); } - cancelInvite(planId: number, targetUserId: number): void { + async cancelInvite(planId: number, targetUserId: number): Promise { this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, targetUserId); try { @@ -752,20 +754,20 @@ export class VacayService { // Plan dissolution // ------------------------------------------------------------------------- - dissolvePlan(userId: number, socketId: string | undefined): void { + async dissolvePlan(userId: number, socketId: string | undefined): Promise { // Dissolution moves every member's entries back to their own plan and copies // the company holidays — atomic, so a failure can't strand entries between plans. - const allUserIds = this.db.transaction(() => { - const plan = this.getActivePlan(userId); + const allUserIds = await this.uow.transactional(async () => { + const plan = await this.getActivePlan(userId); const isOwnerFlag = plan.owner_id === userId; - const userIds = this.getPlanUsers(plan.id).map(u => u.id); + const userIds = (await this.getPlanUsers(plan.id)).map(u => u.id); const companyHolidays = this.db.all<{ date: string; note: string }>('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?', plan.id); if (isOwnerFlag) { const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", plan.id); for (const m of members) { - const memberPlan = this.getOwnPlan(m.user_id); + const memberPlan = await this.getOwnPlan(m.user_id); this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', memberPlan.id, plan.id, m.user_id); for (const ch of companyHolidays) { this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', memberPlan.id, ch.date, ch.note); @@ -773,7 +775,7 @@ export class VacayService { } this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ?', plan.id); } else { - const ownPlan = this.getOwnPlan(userId); + const ownPlan = await this.getOwnPlan(userId); this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', ownPlan.id, plan.id, userId); for (const ch of companyHolidays) { this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', ownPlan.id, ch.date, ch.note); @@ -787,14 +789,14 @@ export class VacayService { allUserIds.filter(id => id !== userId).forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:dissolved' })); } catch { /* */ } // Everyone's entries just moved back to their own plans — refresh read-only viewers. - this.notifyShareViewers(allUserIds, socketId); + await this.notifyShareViewers(allUserIds, socketId); } // ------------------------------------------------------------------------- // Available users // ------------------------------------------------------------------------- - getAvailableUsers(userId: number, planId: number) { + async getAvailableUsers(userId: number, planId: number) { return this.db.all(` SELECT u.id, u.username, u.email FROM users u WHERE u.id != ? @@ -818,7 +820,7 @@ export class VacayService { // the owner's entries in whatever plan the owner is currently active in. /** Like getActivePlan, but never lazily creates a plan for the user. */ - private peekActivePlan(userId: number): VacayPlan | undefined { + private async peekActivePlan(userId: number): Promise { const membership = this.db.get<{ plan_id: number }>(` SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' `, userId); @@ -829,8 +831,8 @@ export class VacayService { } /** Colors already taken in the viewer's own calendar (their plan's members). */ - private viewerColors(viewerId: number): Set { - const plan = this.peekActivePlan(viewerId); + private async viewerColors(viewerId: number): Promise> { + const plan = await this.peekActivePlan(viewerId); if (!plan) return new Set(['#6366f1']); const rows = this.db.all<{ color: string }>('SELECT color FROM vacay_user_colors WHERE plan_id = ?', plan.id); return new Set(rows.length > 0 ? rows.map(r => r.color) : ['#6366f1']); @@ -842,8 +844,8 @@ export class VacayService { * members or an earlier share — otherwise two people on the default indigo * would be indistinguishable in the overlay. */ - private shareDisplayColor(ownerId: number, usedColors: Set): string { - const plan = this.peekActivePlan(ownerId); + private async shareDisplayColor(ownerId: number, usedColors: Set): Promise { + const plan = await this.peekActivePlan(ownerId); const row = plan ? this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', ownerId, plan.id) : undefined; @@ -857,12 +859,12 @@ export class VacayService { } /** Users the viewer already sees in full via their active plan (owner + members). */ - private viewerCoMemberIds(viewerId: number): Set { - const plan = this.peekActivePlan(viewerId); - return new Set(plan ? this.getPlanUsers(plan.id).map(u => u.id) : []); + private async viewerCoMemberIds(viewerId: number): Promise> { + const plan = await this.peekActivePlan(viewerId); + return new Set(plan ? (await this.getPlanUsers(plan.id)).map(u => u.id) : []); } - listShares(userId: number) { + async listShares(userId: number) { // Usernames only, like the share picker — emails stay out of the share surface. const outgoing = this.db.all<{ id: number; user_id: number; username: string }>(` SELECT s.id, s.user_id, u.username @@ -876,19 +878,25 @@ export class VacayService { `, userId); // Shares from someone the viewer is meanwhile fused with lie dormant — the // plan already shows that calendar in full. They resume after dissolution. - const coMembers = this.viewerCoMemberIds(userId); - const usedColors = this.viewerColors(userId); - const incoming = incomingRows.filter(s => !coMembers.has(s.owner_id)).map(s => ({ - id: s.id, - owner_id: s.owner_id, - username: s.username, - color: this.shareDisplayColor(s.owner_id, usedColors), - hidden: !!s.hidden, - })); + const coMembers = await this.viewerCoMemberIds(userId); + const usedColors = await this.viewerColors(userId); + // `map` cannot await, and `shareDisplayColor` mutates `usedColors` as it + // hands colors out, so the projection runs as an explicit loop — same rows, + // same order, same color-allocation sequence. + const incoming = []; + for (const s of incomingRows.filter(s => !coMembers.has(s.owner_id))) { + incoming.push({ + id: s.id, + owner_id: s.owner_id, + username: s.username, + color: await this.shareDisplayColor(s.owner_id, usedColors), + hidden: !!s.hidden, + }); + } return { outgoing, incoming }; } - shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } { + async shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): Promise<{ error?: string; status?: number }> { if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 }; const targetUser = this.db.get('SELECT id FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', targetUserId); @@ -898,7 +906,7 @@ export class VacayService { if (existing) return { error: 'Already shared', status: 400 }; // Plan members already see the whole calendar — sharing with them is moot. - if (this.getPlanUsers(this.getActivePlanId(ownerId)).find(u => u.id === targetUserId)) { + if ((await this.getPlanUsers(await this.getActivePlanId(ownerId))).find(u => u.id === targetUserId)) { return { error: 'User is already in your calendar', status: 400 }; } @@ -915,7 +923,7 @@ export class VacayService { return {}; } - removeShare(shareId: number, userId: number, socketId?: string): boolean { + async removeShare(shareId: number, userId: number, socketId?: string): Promise { const share = this.db.get('SELECT * FROM vacay_shares WHERE id = ?', shareId); // The owner revokes, the viewer removes — both may delete, nobody else. if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false; @@ -927,7 +935,7 @@ export class VacayService { return true; } - setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): boolean { + async setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): Promise { const share = this.db.get('SELECT * FROM vacay_shares WHERE id = ?', shareId); if (!share || share.user_id !== userId) return false; this.db.run('UPDATE vacay_shares SET hidden = ? WHERE id = ?', hidden ? 1 : 0, shareId); @@ -938,8 +946,8 @@ export class VacayService { return true; } - getShareAvailableUsers(userId: number) { - const planId = this.getActivePlanId(userId); + async getShareAvailableUsers(userId: number) { + const planId = await this.getActivePlanId(userId); // Username only — unlike the fusion picker this lists users from other plans // too, so exposing their emails here would widen the instance directory. return this.db.all(` @@ -956,10 +964,10 @@ export class VacayService { `, userId, userId, planId, planId); } - getSharedCalendars(viewerId: number, year: string) { + async getSharedCalendars(viewerId: number, year: string) { // Shared calendars are drawn into the viewer's grid, so they load over the // viewer's range (#737) even when the owner's leave year is shaped differently. - const { start, end } = this.viewerGridWindow(year, viewerId); + const { start, end } = await this.viewerGridWindow(year, viewerId); const shares = this.db.all<{ id: number; owner_id: number; hidden: number; username: string }>(` SELECT s.id, s.owner_id, s.hidden, u.username FROM vacay_shares s JOIN users u ON s.owner_id = u.id @@ -967,13 +975,18 @@ export class VacayService { `, viewerId); // Same dormancy rule as listShares: fused co-members are already fully visible. - const coMembers = this.viewerCoMemberIds(viewerId); - const usedColors = this.viewerColors(viewerId); - return shares.filter(s => !coMembers.has(s.owner_id)).map(s => { - const color = this.shareDisplayColor(s.owner_id, usedColors); - const plan = this.peekActivePlan(s.owner_id); + const coMembers = await this.viewerCoMemberIds(viewerId); + const usedColors = await this.viewerColors(viewerId); + // `map` cannot await, and `shareDisplayColor` mutates `usedColors` as it + // hands colors out, so the projection runs as an explicit loop — same rows, + // same order, same color-allocation sequence. + const calendars = []; + for (const s of shares.filter(s => !coMembers.has(s.owner_id))) { + const color = await this.shareDisplayColor(s.owner_id, usedColors); + const plan = await this.peekActivePlan(s.owner_id); if (!plan) { - return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] }; + calendars.push({ share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] }); + continue; } const entries = this.db.all( 'SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date', @@ -985,36 +998,37 @@ export class VacayService { const companyHolidays = plan.company_holidays_enabled ? this.db.all('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date', plan.id, start, end) : []; - return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays }; - }); + calendars.push({ share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays }); + } + return calendars; } // ------------------------------------------------------------------------- // Years // ------------------------------------------------------------------------- - listYears(planId: number): number[] { + async listYears(planId: number): Promise { const rows = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId); return rows.map(y => y.year); } - addYear(planId: number, year: number, socketId: string | undefined): number[] { + async addYear(planId: number, year: number, socketId: string | undefined): Promise { // A duplicate year is a no-op (the legacy blanket try/catch was written for // exactly this constraint hit); real errors now propagate instead of being // swallowed. The insert + per-user seeding runs atomically. const exists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year); if (!exists) { - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)', planId, year); const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = this.getPlanUsers(planId); + const users = await this.getPlanUsers(planId); for (const u of users) { let carriedOver = 0; if (carryOverEnabled) { const prevConfig = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year - 1); if (prevConfig) { - const used = this.usedDays(u.id, planId, year - 1); + const used = await this.usedDays(u.id, planId, year - 1); const total = prevConfig.vacation_days + prevConfig.carried_over; carriedOver = Math.max(0, total - used); } @@ -1023,21 +1037,21 @@ export class VacayService { } }); } - this.notifyPlanUsers(planId, socketId, 'vacay:settings'); - return this.listYears(planId); + await this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return await this.listYears(planId); } - deleteYear(planId: number, year: number, socketId: string | undefined): number[] { + async deleteYear(planId: number, year: number, socketId: string | undefined): Promise { // Year removal deletes across four tables and recomputes the next year's // carry-over — atomic, so a failure can't leave entries without their year. - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year); // Members can be on differently shaped leave years (#737), so entries go per // author over that author's period rather than by one shared year prefix. // Authors are read off the entries themselves so orphans are cleared too. const authors = this.db.all<{ user_id: number }>('SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?', planId); for (const { user_id } of authors) { - const { start, end } = this.resolveYearWindow(user_id, year); + const { start, end } = await this.resolveYearWindow(user_id, year); this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ?', planId, user_id, start, end); } // Company holidays belong to the plan, not to a member, and every member sees @@ -1045,8 +1059,13 @@ export class VacayService { // range is therefore the intersection of all member windows — anything outside // it still sits inside a period somebody else has not deleted. const owner = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId); - const members = this.getPlanUsers(planId); - const windows = (members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]).map(id => this.resolveYearWindow(id, year)); + const members = await this.getPlanUsers(planId); + // `map` cannot await the per-member window read, so it runs as an explicit + // loop — same ids, same order. + const windows = []; + for (const id of members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]) { + windows.push(await this.resolveYearWindow(id, year)); + } const holidayStart = windows.reduce((a, w) => (w.start > a ? w.start : a), windows[0].start); const holidayEnd = windows.reduce((a, w) => (w.end < a ? w.end : a), windows[0].end); if (holidayStart < holidayEnd) { @@ -1059,7 +1078,7 @@ export class VacayService { if (nextYearExists) { const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = this.getPlanUsers(planId); + const users = await this.getPlanUsers(planId); const prevYear = this.db.get<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1', planId, year + 1); for (const u of users) { @@ -1067,7 +1086,7 @@ export class VacayService { if (carryOverEnabled && prevYear) { const prevConfig = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, prevYear.year); if (prevConfig) { - const used = this.usedDays(u.id, planId, prevYear.year); + const used = await this.usedDays(u.id, planId, prevYear.year); const total = prevConfig.vacation_days + prevConfig.carried_over; carry = Math.max(0, total - used); } @@ -1077,8 +1096,8 @@ export class VacayService { } }); - this.notifyPlanUsers(planId, socketId, 'vacay:settings'); - return this.listYears(planId); + await this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return await this.listYears(planId); } // ------------------------------------------------------------------------- @@ -1090,8 +1109,8 @@ export class VacayService { * window (#737) — a shifted year spans two calendar years, so the old year prefix * would drop the second half. For 'calendar' the range is Jan 1 – Dec 31 again. */ - getEntries(planId: number, year: string, viewerId?: number) { - const { start, end } = this.viewerGridWindow(year, viewerId); + async getEntries(planId: number, year: string, viewerId?: number) { + const { start, end } = await this.viewerGridWindow(year, viewerId); const entries = this.db.all(` SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color FROM vacay_entries e @@ -1103,7 +1122,7 @@ export class VacayService { return { entries, companyHolidays }; } - toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action?: string; fraction?: number; kind?: string; error?: string } { + async toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): Promise<{ action?: string; fraction?: number; kind?: string; error?: string }> { const frac = normalizeFraction(fraction); const knd = normalizeKind(kind); const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); @@ -1114,32 +1133,32 @@ export class VacayService { // clicking a different type or fraction converts it in place (#552/#1074). if (existing.fraction === frac && (existing.kind || 'vacation') === knd) { this.db.run('DELETE FROM vacay_entries WHERE id = ?', existing.id); - this.notifyPlanUsers(planId, socketId); + await this.notifyPlanUsers(planId, socketId); return { action: 'removed' }; } // Removing a stray entry on a blocked day stays possible; keeping one // there (converted in place) does not. if (weekendBlocked) return { error: 'weekend_blocked' }; this.db.run('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?', frac, knd, existing.id); - this.notifyPlanUsers(planId, socketId); + await this.notifyPlanUsers(planId, socketId); return { action: 'updated', fraction: frac, kind: knd }; } if (weekendBlocked) return { error: 'weekend_blocked' }; this.db.run('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)', planId, userId, date, '', frac, knd); - this.notifyPlanUsers(planId, socketId); + await this.notifyPlanUsers(planId, socketId); return { action: 'added', fraction: frac, kind: knd }; } - toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } { + async toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): Promise<{ action: string }> { const existing = this.db.get<{ id: number }>('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date); if (existing) { this.db.run('DELETE FROM vacay_company_holidays WHERE id = ?', existing.id); - this.notifyPlanUsers(planId, socketId); + await this.notifyPlanUsers(planId, socketId); return { action: 'removed' }; } else { this.db.run('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', planId, date, note || ''); this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date); - this.notifyPlanUsers(planId, socketId); + await this.notifyPlanUsers(planId, socketId); return { action: 'added' }; } } @@ -1148,14 +1167,17 @@ export class VacayService { // Stats // ------------------------------------------------------------------------- - getStats(planId: number, year: number) { + async getStats(planId: number, year: number) { const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = this.getPlanUsers(planId); + const users = await this.getPlanUsers(planId); - return users.map(u => { - const used = this.usedDays(u.id, planId, year); - const compUsed = this.compUsedDays(u.id, planId, year); + // `map` cannot await, and the body also writes next year's carry-over, so + // the projection runs as an explicit loop — same users, same order, same writes. + const rows = []; + for (const u of users) { + const used = await this.usedDays(u.id, planId, year); + const compUsed = await this.compUsedDays(u.id, planId, year); const config = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year); const vacationDays = config ? config.vacation_days : 30; const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0; @@ -1164,7 +1186,7 @@ export class VacayService { const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, planId); // The period this row was computed over (#737) — the UI labels the window and // the carry-over source with it instead of assuming Jan–Dec / year − 1. - const window = this.resolveYearWindow(u.id, year); + const window = await this.resolveYearWindow(u.id, year); const nextYearExists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year + 1); if (nextYearExists && carryOverEnabled) { @@ -1175,32 +1197,33 @@ export class VacayService { `, u.id, planId, year + 1, carry, carry); } - return { + rows.push({ user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1', year, vacation_days: vacationDays, carried_over: carriedOver, total_available: total, used, remaining, comp_used: compUsed, window_start: window.start, window_end: window.end, - }; - }); + }); + } + return rows; } - updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void { + async updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): Promise { this.db.run(` INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days `, userId, planId, year, vacationDays); - this.notifyPlanUsers(planId, socketId); + await this.notifyPlanUsers(planId, socketId); } // ------------------------------------------------------------------------- // GET /plan composite // ------------------------------------------------------------------------- - getPlanData(userId: number) { - const plan = this.getActivePlan(userId); + async getPlanData(userId: number) { + const plan = await this.getActivePlan(userId); const activePlanId = plan.id; - const users = this.getPlanUsers(activePlanId).map(u => { + const users = (await this.getPlanUsers(activePlanId)).map(u => { const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, activePlanId); return { ...u, color: colorRow?.color || '#6366f1' }; }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 6e3bdaade..5ee4cc6ef 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -184,7 +184,7 @@ export async function createMcpTestRegistry(): Promise { daysService, permissionsService, budgetService, - new VacayService(dbService, realtimeService, notificationsStub()), + new VacayService(dbService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)), realtimeService, new UnsplashService(dbService, new RuntimeEnvService(), generalStorage), generalStorage, @@ -229,7 +229,7 @@ export async function createMcpTestRegistry(): Promise { new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)), new AssignmentsMcp(assignmentsService, daysService, authService, guards), new CollabMcp(collabService, authService, addonsService, guards), - new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService), + new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)), authService, addonsService), new SchoolHolidaysMcp(new SchoolHolidaysService(dbService, await createTestUnitOfWork(dbService.connection)), guards), new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards), new TripPromptsMcp(tripsService, readModelService, packingService, addonsService), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 1cc9a2c7b..eea3626ab 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -97,7 +97,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection)); const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage); const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection)); - const vacay = new VacayService(dbs, realtime, notificationsStub()); + const vacay = new VacayService(dbs, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection)); const days = new DaysService(dbs, permissions, realtime, queryHelpers, await createTestUnitOfWork(dbs.connection)); const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage); const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 2dc382ef4..481ba4eb3 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -84,7 +84,7 @@ afterEach(() => { delete process.env.NODE_ENV; }); describe('AdminController users', () => { it('lists, creates (201 + audit), maps an error', async () => { - expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); + expect(await adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial)); expect(await c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } }); @@ -184,12 +184,12 @@ describe('AdminController error envelope fallbacks', () => { describe('AdminController read-only getters', () => { it('return service values verbatim', async () => { expect(await adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); - expect(adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); + expect(await adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); expect(await adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); - expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); + expect(await adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); expect(await adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] }); - expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); + expect(await adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); expect(await adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 74d83819f..021f2ce4c 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -112,6 +112,7 @@ beforeAll(async () => { await makeNotificationsService(dbs, realtime), userCleanup, realtime, + await createTestUnitOfWork(dbs.connection), ); }); @@ -147,10 +148,10 @@ afterAll(() => { // ── listUsers ───────────────────────────────────────────────────────────────── describe('listUsers', () => { - it('ADMIN-SVC-001 — returns all users with online:false', () => { + it('ADMIN-SVC-001 — returns all users with online:false', async () => { createUser(testDb); createUser(testDb); - const users = listUsers() as any[]; + const users = (await listUsers()) as any[]; expect(users.length).toBeGreaterThanOrEqual(2); expect(users.every((u: any) => u.online === false)).toBe(true); }); @@ -159,38 +160,38 @@ describe('listUsers', () => { // ── createUser ──────────────────────────────────────────────────────────────── describe('createUser (service)', () => { - it('ADMIN-SVC-002 — creates a user successfully', () => { - const result = svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' }) as any; + it('ADMIN-SVC-002 — creates a user successfully', async () => { + const result = (await svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' })) as any; expect(result.user).toBeDefined(); expect(result.user.email).toBe('new@test.com'); }); - it('ADMIN-SVC-003 — returns 400 when username is missing', () => { - const result = svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' }) as any; + it('ADMIN-SVC-003 — returns 400 when username is missing', async () => { + const result = (await svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' })) as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-004 — returns 400 for invalid role', () => { - const result = svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' }) as any; + it('ADMIN-SVC-004 — returns 400 for invalid role', async () => { + const result = (await svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' })) as any; expect(result.status).toBe(400); expect(result.error).toMatch(/invalid role/i); }); - it('ADMIN-SVC-005 — returns 409 for duplicate username', () => { + it('ADMIN-SVC-005 — returns 409 for duplicate username', async () => { createUser(testDb); const { user } = createUser(testDb); - const result = svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' }) as any; + const result = (await svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' })) as any; expect(result.status).toBe(409); }); - it('ADMIN-SVC-006 — returns 409 for duplicate email', () => { + it('ADMIN-SVC-006 — returns 409 for duplicate email', async () => { const { user } = createUser(testDb); - const result = svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' }) as any; + const result = (await svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' })) as any; expect(result.status).toBe(409); }); - it('ADMIN-SVC-007 — returns 400 for weak password', () => { - const result = svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' }) as any; + it('ADMIN-SVC-007 — returns 400 for weak password', async () => { + const result = (await svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' })) as any; expect(result.status).toBe(400); }); }); @@ -198,47 +199,47 @@ describe('createUser (service)', () => { // ── updateUser ──────────────────────────────────────────────────────────────── describe('updateUser', () => { - it('ADMIN-SVC-008 — updates username successfully', () => { + it('ADMIN-SVC-008 — updates username successfully', async () => { const { user } = createUser(testDb); - const result = updateUser(String(user.id), { username: 'updatedname' }) as any; + const result = (await updateUser(String(user.id), { username: 'updatedname' })) as any; expect(result.user).toBeDefined(); expect(result.user.username).toBe('updatedname'); }); - it('ADMIN-SVC-009 — returns 404 for non-existent user', () => { - const result = updateUser('99999', { username: 'ghost' }) as any; + it('ADMIN-SVC-009 — returns 404 for non-existent user', async () => { + const result = (await updateUser('99999', { username: 'ghost' })) as any; expect(result.status).toBe(404); }); - it('ADMIN-SVC-010 — returns 400 for invalid role', () => { + it('ADMIN-SVC-010 — returns 400 for invalid role', async () => { const { user } = createUser(testDb); - const result = updateUser(String(user.id), { role: 'superadmin' }) as any; + const result = (await updateUser(String(user.id), { role: 'superadmin' })) as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-011 — returns 409 when username is taken', () => { + it('ADMIN-SVC-011 — returns 409 when username is taken', async () => { const { user: u1 } = createUser(testDb); const { user: u2 } = createUser(testDb); - const result = updateUser(String(u2.id), { username: u1.username }) as any; + const result = (await updateUser(String(u2.id), { username: u1.username })) as any; expect(result.status).toBe(409); }); - it('ADMIN-SVC-012 — returns 409 when email is taken', () => { + it('ADMIN-SVC-012 — returns 409 when email is taken', async () => { const { user: u1 } = createUser(testDb); const { user: u2 } = createUser(testDb); - const result = updateUser(String(u2.id), { email: u1.email }) as any; + const result = (await updateUser(String(u2.id), { email: u1.email })) as any; expect(result.status).toBe(409); }); - it('ADMIN-SVC-013 — returns 400 for weak password', () => { + it('ADMIN-SVC-013 — returns 400 for weak password', async () => { const { user } = createUser(testDb); - const result = updateUser(String(user.id), { password: 'weak' }) as any; + const result = (await updateUser(String(user.id), { password: 'weak' })) as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-014 — tracks changed fields in result', () => { + it('ADMIN-SVC-014 — tracks changed fields in result', async () => { const { user } = createUser(testDb); - const result = updateUser(String(user.id), { username: 'newname', role: 'admin' }) as any; + const result = (await updateUser(String(user.id), { username: 'newname', role: 'admin' })) as any; expect(result.changed).toContain('username'); expect(result.changed).toContain('role'); }); @@ -270,8 +271,8 @@ describe('deleteUser', () => { // ── getStats ────────────────────────────────────────────────────────────────── describe('getStats', () => { - it('ADMIN-SVC-018 — returns numeric counts for all stats', () => { - const stats = getStats() as any; + it('ADMIN-SVC-018 — returns numeric counts for all stats', async () => { + const stats = (await getStats()) as any; expect(typeof stats.totalUsers).toBe('number'); expect(typeof stats.totalTrips).toBe('number'); expect(typeof stats.totalPlaces).toBe('number'); @@ -299,22 +300,22 @@ describe('Permissions', () => { // ── getAuditLog ─────────────────────────────────────────────────────────────── describe('getAuditLog', () => { - it('ADMIN-SVC-021 — returns entries array with total', () => { - const result = getAuditLog({}) as any; + it('ADMIN-SVC-021 — returns entries array with total', async () => { + const result = (await getAuditLog({})) as any; expect(Array.isArray(result.entries)).toBe(true); expect(typeof result.total).toBe('number'); expect(result.limit).toBe(100); expect(result.offset).toBe(0); }); - it('ADMIN-SVC-022 — respects limit and offset params', () => { - const result = getAuditLog({ limit: '10', offset: '0' }) as any; + it('ADMIN-SVC-022 — respects limit and offset params', async () => { + const result = (await getAuditLog({ limit: '10', offset: '0' })) as any; expect(result.limit).toBe(10); expect(result.offset).toBe(0); }); - it('ADMIN-SVC-023 — caps limit at 500', () => { - const result = getAuditLog({ limit: '9999' }) as any; + it('ADMIN-SVC-023 — caps limit at 500', async () => { + const result = (await getAuditLog({ limit: '9999' })) as any; expect(result.limit).toBe(500); }); }); @@ -322,24 +323,24 @@ describe('getAuditLog', () => { // ── getAuditLog — JSON details parsing ─────────────────────────────────────── describe('getAuditLog — JSON details', () => { - it('ADMIN-SVC-045 — parses JSON details when present', () => { + it('ADMIN-SVC-045 — parses JSON details when present', async () => { const { user } = createUser(testDb); testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run( user.id, 'test_action', JSON.stringify({ key: 'val' }) ); - const result = getAuditLog({}) as any; + const result = (await getAuditLog({})) as any; expect(result.entries.length).toBeGreaterThanOrEqual(1); const entry = result.entries.find((e: any) => e.action === 'test_action'); expect(entry).toBeDefined(); expect(entry.details).toEqual({ key: 'val' }); }); - it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', () => { + it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', async () => { const { user } = createUser(testDb); testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run( user.id, 'bad_json_action', 'not-valid-json{' ); - const result = getAuditLog({}) as any; + const result = (await getAuditLog({})) as any; const entry = result.entries.find((e: any) => e.action === 'bad_json_action'); expect(entry).toBeDefined(); // Was { _parse_error: true } before the 2026-08 quirk fix — the admin UI @@ -477,8 +478,8 @@ describe('checkVersion', () => { // ── listAddons ──────────────────────────────────────────────────────────────── describe('listAddons', () => { - it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', () => { - const result = listAddons() as any[]; + it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', async () => { + const result = (await listAddons()) as any[]; expect(Array.isArray(result)).toBe(true); expect(result.length).toBeGreaterThan(0); const addonIds = result.map((a: any) => a.id); @@ -597,10 +598,10 @@ describe('version-check job', () => { describe('admin quirk fixes (post-fold)', () => { - it('ADMIN-SVC-072 — updateUser rejects an empty username/email instead of silently no-opping', () => { + it('ADMIN-SVC-072 — updateUser rejects an empty username/email instead of silently no-opping', async () => { const { user } = createUser(testDb); - expect(updateUser(String(user.id), { username: '' }) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' }); - expect(updateUser(String(user.id), { email: ' ' }) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' }); + expect((await updateUser(String(user.id), { username: '' })) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' }); + expect((await updateUser(String(user.id), { email: ' ' })) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' }); // The row is untouched. const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string }; expect(row.username).toBe(user.username); @@ -619,7 +620,7 @@ const mcpTokenCount = (id: number): number => (testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n; describe('admin password reset revokes what an intruder already holds', () => { - it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', () => { + it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', async () => { // An admin sets somebody else's password for one reason: the account is // believed compromised. Without the bump, verifyJwtAndLoadUser keeps // accepting every cookie the intruder holds, and the one action taken to @@ -627,26 +628,26 @@ describe('admin password reset revokes what an intruder already holds', () => { const { user } = createUser(testDb); const before = pv(user.id); - updateUser(String(user.id), { password: 'ANewStrongPass123!' }); + await updateUser(String(user.id), { password: 'ANewStrongPass123!' }); expect(pv(user.id)).toBe(before + 1); }); - it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', () => { + it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); - updateUser(String(user.id), { password: 'ANewStrongPass123!' }); + await updateUser(String(user.id), { password: 'ANewStrongPass123!' }); expect(mcpTokenCount(user.id)).toBe(0); }); - it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => { + it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', async () => { const { user } = createUser(testDb); const before = pv(user.id); testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); - updateUser(String(user.id), { username: 'renamed' }); + await updateUser(String(user.id), { username: 'renamed' }); expect(pv(user.id)).toBe(before); expect(mcpTokenCount(user.id)).toBe(1); @@ -654,14 +655,14 @@ describe('admin password reset revokes what an intruder already holds', () => { }); describe('resetUserMfa', () => { - it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', () => { + it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', async () => { // The passkey half has existed since passkeys landed; TOTP never had an // answer, which left "somebody on the trip lost their phone" with no way out // short of an operator reaching into the database. const admin = createAdmin(testDb); const { user } = createUserWithMfa(testDb); - const result = svc.resetUserMfa(String(user.id), admin.user.id) as { success?: boolean; email?: string }; + const result = (await svc.resetUserMfa(String(user.id), admin.user.id)) as { success?: boolean; email?: string }; expect(result.success).toBe(true); expect(result.email).toBe(user.email); @@ -673,22 +674,22 @@ describe('resetUserMfa', () => { expect(row.mfa_backup_codes).toBeNull(); }); - it('ADMIN-SVC-084 — refuses to strip the callers own second factor', () => { + it('ADMIN-SVC-084 — refuses to strip the callers own second factor', async () => { // Reachable from a stolen admin session otherwise, and it would take the // second factor off the very account that session came from. The // self-service path in Settings asks for the current password. const admin = createAdmin(testDb); - const result = svc.resetUserMfa(String(admin.user.id), admin.user.id) as { error?: string; status?: number }; + const result = (await svc.resetUserMfa(String(admin.user.id), admin.user.id)) as { error?: string; status?: number }; expect(result.status).toBe(400); expect(result.error).toMatch(/your own/i); }); - it('ADMIN-SVC-085 — 404 for a user that is not there', () => { + it('ADMIN-SVC-085 — 404 for a user that is not there', async () => { const admin = createAdmin(testDb); - expect(svc.resetUserMfa('99999', admin.user.id) as { status?: number }).toMatchObject({ status: 404 }); + expect((await svc.resetUserMfa('99999', admin.user.id)) as { status?: number }).toMatchObject({ status: 404 }); }); }); diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 7a6d22033..81bec036e 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -90,6 +90,7 @@ beforeAll(async () => { await makeNotificationsService(dbs, realtime), userCleanup, realtime, + await createTestUnitOfWork(dbs.connection), ); }); const checkAndNotifyVersion = () => svc.checkAndNotifyVersion(); diff --git a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts index 7db14c809..6a7bcc770 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts @@ -67,8 +67,9 @@ import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/doc // off, so the boolean discriminant stops discriminating. The domain's own // predicate is what every call site uses instead. import { docFailed } from '../../../../src/nest/doc-sync/document-provider'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; -const svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry); +let svc: DocSyncConfigService; const TOKEN = 'paperless-token-1234'; @@ -105,20 +106,21 @@ function linkInput(connectionId: number, over: Partial = {}): }; } -function link(connectionId: number, over: Partial = {}): LinkRow { - const result = svc.createLink(TRIP, OWNER, linkInput(connectionId, over)); +async function link(connectionId: number, over: Partial = {}): Promise { + const result = await svc.createLink(TRIP, OWNER, linkInput(connectionId, over)); if (docFailed(result)) throw new Error(`fixture link refused: ${JSON.stringify(result.error)}`); return result.data; } -function storedSecret(connectionId: number): string | undefined { - const row = svc.getConnection(connectionId) as ConnectionRow; +async function storedSecret(connectionId: number): Promise { + const row = (await svc.getConnection(connectionId)) as ConnectionRow; return svc.toRef(row).secrets.api_token; } -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry, await createTestUnitOfWork(testDb)); }); beforeEach(() => { @@ -142,7 +144,7 @@ describe('upsertConnection secrets', () => { it('keeps the stored credential when the form comes back with the field blank', async () => { const created = await connect(); await connect({ credentials: { api_token: '' } }); - expect(storedSecret(created.id)).toBe(TOKEN); + expect(await storedSecret(created.id)).toBe(TOKEN); }); it('keeps it when the form posts back the mask it was shown', async () => { @@ -150,19 +152,19 @@ describe('upsertConnection secrets', () => { // form from the masked view and never has to hold the real credential. const created = await connect(); await connect({ credentials: { api_token: DOCSYNC_SECRET_MASK } }); - expect(storedSecret(created.id)).toBe(TOKEN); + expect(await storedSecret(created.id)).toBe(TOKEN); }); it('keeps it when the field is missing from the payload altogether', async () => { const created = await connect(); await connect({ credentials: {} }); - expect(storedSecret(created.id)).toBe(TOKEN); + expect(await storedSecret(created.id)).toBe(TOKEN); }); it('replaces it when a new credential actually arrives', async () => { const created = await connect(); await connect({ credentials: { api_token: 'rotated-token' } }); - expect(storedSecret(created.id)).toBe('rotated-token'); + expect(await storedSecret(created.id)).toBe('rotated-token'); }); it('updates the trip\'s connection in place rather than adding a second one', async () => { @@ -194,10 +196,10 @@ describe('upsertConnection secrets', () => { expect(docFailed(result)).toBe(true); expect(docFailed(result) && result.error.code).toBe('unauthorized'); - const row = svc.getConnection(created.id) as ConnectionRow; + const row = await svc.getConnection(created.id) as ConnectionRow; expect(row.base_url).toBe('https://paperless.example.com'); expect(row.owner_user_id).toBe(OWNER); - expect(storedSecret(created.id)).toBe(TOKEN); + expect(await storedSecret(created.id)).toBe(TOKEN); }); it('refuses the mask as well, since the mask is not the credential', async () => { @@ -214,22 +216,22 @@ describe('upsertConnection secrets', () => { const moved = await connect({ baseUrl: 'https://paperless.example.net', credentials: { api_token: 'new-token' } }); expect(moved.id).toBe(created.id); expect(moved.base_url).toBe('https://paperless.example.net'); - expect(storedSecret(created.id)).toBe('new-token'); + expect(await storedSecret(created.id)).toBe('new-token'); }); it('drops a secret the provider earned for the old address, even when the form is complete', async () => { const conn = await connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials: { username: 'anna', password: 'nas-pw' } }); - svc.saveEarnedSecret(conn.id, 'device_token', 'a1b2c3:DEVICE-7'); + await svc.saveEarnedSecret(conn.id, 'device_token', 'a1b2c3:DEVICE-7'); await connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.net:5001', credentials: { username: 'anna', password: 'nas-pw' } }); - expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).secrets).toEqual({ password: 'nas-pw' }); + expect(svc.toRef((await svc.getConnection(conn.id)) as ConnectionRow).secrets).toEqual({ password: 'nas-pw' }); }); it('reads a path or a trailing slash on the same server as the same address', async () => { const created = await connect(); await connect({ baseUrl: 'https://paperless.example.com/paperless/', credentials: {} }); - expect(storedSecret(created.id)).toBe(TOKEN); + expect(await storedSecret(created.id)).toBe(TOKEN); }); it('reads another port or scheme as another server', async () => { @@ -251,13 +253,13 @@ describe('upsertConnection secrets', () => { it('stays with the person whose secret is stored when somebody else saves the form blank', async () => { const created = await connect(); await connect({ credentials: {} }, MEMBER); - expect((svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(OWNER); + expect((await svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(OWNER); }); it('moves to the person who typed a new secret in', async () => { const created = await connect(); await connect({ credentials: { api_token: 'members-own-token' } }, MEMBER); - expect((svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(MEMBER); + expect((await svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(MEMBER); }); it('stays put when only an optional secret is typed, since that runs against the stored password', async () => { @@ -265,7 +267,7 @@ describe('upsertConnection secrets', () => { const created = await connect({ ...nas, credentials: { username: 'anna', password: 'nas-pw' } }); await connect({ ...nas, credentials: { username: 'anna', otp_code: '123456' } }, MEMBER); - const row = svc.getConnection(created.id) as ConnectionRow; + const row = await svc.getConnection(created.id) as ConnectionRow; expect(row.owner_user_id).toBe(OWNER); expect(svc.toRef(row).secrets).toEqual({ password: 'nas-pw', otp_code: '123456' }); }); @@ -286,7 +288,7 @@ describe('upsertConnection secrets', () => { credentials: { login_name: 'anna' }, allowInsecureTls: false, }); - const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'nextcloud') as ConnectionRow; + const row = (await svc.listConnections(TRIP)).find((c) => c.provider_id === 'nextcloud') as ConnectionRow; expect(svc.toRef(row).settings).toMatchObject({ login_name: 'anna', base_path: '/TREK' }); expect(svc.toRef(row).secrets.app_password).toBe('app-pw'); }); @@ -295,7 +297,7 @@ describe('upsertConnection secrets', () => { describe('publicConnection', () => { it('reports that a secret is set without ever carrying its value', async () => { const created = await connect(); - const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow); + const view = await svc.publicConnection((await svc.getConnection(created.id)) as ConnectionRow); expect(view.secrets).toEqual({ api_token: DOCSYNC_SECRET_MASK }); expect(JSON.stringify(view)).not.toContain(TOKEN); }); @@ -310,14 +312,14 @@ describe('publicConnection', () => { allowInsecureTls: false, }); expect(created.success).toBe(true); - const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'synologydrive') as ConnectionRow; - expect(svc.publicConnection(row).secrets).toEqual({ password: DOCSYNC_SECRET_MASK }); + const row = (await svc.listConnections(TRIP)).find((c) => c.provider_id === 'synologydrive') as ConnectionRow; + expect((await svc.publicConnection(row)).secrets).toEqual({ password: DOCSYNC_SECRET_MASK }); }); it('reads a capabilities blob left behind by another build as unknown, not as a crash', async () => { const created = await connect(); testDb.prepare('UPDATE document_connections SET capabilities = ? WHERE id = ?').run('{not json', created.id); - const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow); + const view = await svc.publicConnection((await svc.getConnection(created.id)) as ConnectionRow); expect(view.capabilities).toBeNull(); }); }); @@ -330,22 +332,22 @@ describe('a secret the provider earned itself', () => { return connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials }); } - function secretsOf(connectionId: number): Readonly> { - return svc.toRef(svc.getConnection(connectionId) as ConnectionRow).secrets; + async function secretsOf(connectionId: number): Promise>> { + return svc.toRef((await svc.getConnection(connectionId)) as ConnectionRow).secrets; } it('is stored through the ref the adapter was handed, encrypted with the form\'s secrets', async () => { const conn = await nas(); - svc.toRef(conn).saveSecret!('device_token', EARNED); + await svc.toRef(conn).saveSecret!('device_token', EARNED); - expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED }); - expect((svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7'); + expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED }); + expect((await svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7'); }); it('never reaches a client, not even as a mask', async () => { const conn = await nas(); - svc.saveEarnedSecret(conn.id, 'device_token', EARNED); - const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow); + await svc.saveEarnedSecret(conn.id, 'device_token', EARNED); + const view = await svc.publicConnection((await svc.getConnection(conn.id)) as ConnectionRow); expect(view.secrets).toEqual({ password: DOCSYNC_SECRET_MASK, otp_code: DOCSYNC_SECRET_MASK }); expect(JSON.stringify(view)).not.toContain('DEVICE-7'); @@ -354,48 +356,48 @@ describe('a secret the provider earned itself', () => { it('survives a form edit, whatever the form sends for the secrets it does show', async () => { const conn = await nas(); - svc.saveEarnedSecret(conn.id, 'device_token', EARNED); + await svc.saveEarnedSecret(conn.id, 'device_token', EARNED); await nas({ username: 'anna', password: 'rotated', otp_code: '' }); await nas({ username: 'anna', password: DOCSYNC_SECRET_MASK }); - expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED }); + expect(await secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED }); }); it('cannot be planted or overwritten through the form', async () => { const conn = await nas(); await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' }); - expect(secretsOf(conn.id).device_token).toBeUndefined(); + expect((await secretsOf(conn.id)).device_token).toBeUndefined(); - svc.saveEarnedSecret(conn.id, 'device_token', EARNED); + await svc.saveEarnedSecret(conn.id, 'device_token', EARNED); await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' }); - expect(secretsOf(conn.id).device_token).toBe(EARNED); - expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).settings).not.toHaveProperty('device_token'); + expect((await secretsOf(conn.id)).device_token).toBe(EARNED); + expect(svc.toRef((await svc.getConnection(conn.id)) as ConnectionRow).settings).not.toHaveProperty('device_token'); }); it('is written against the row as it is now, so a run holding an old ref cannot undo a password edit', async () => { const conn = await nas(); const ref = svc.toRef(conn); await nas({ username: 'anna', password: 'rotated' }); - ref.saveSecret!('device_token', EARNED); + await ref.saveSecret!('device_token', EARNED); - expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED }); + expect(await secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED }); }); it('is dropped with null, and the form\'s secrets stay', async () => { const conn = await nas(); - svc.saveEarnedSecret(conn.id, 'device_token', EARNED); - svc.saveEarnedSecret(conn.id, 'device_token', null); + await svc.saveEarnedSecret(conn.id, 'device_token', EARNED); + await svc.saveEarnedSecret(conn.id, 'device_token', null); - expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' }); + expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' }); }); it('brings back no connection that was deleted while a run still held its ref', async () => { const conn = await nas(); const ref = svc.toRef(conn); - svc.deleteConnection(conn.id); + await svc.deleteConnection(conn.id); - ref.saveSecret!('device_token', EARNED); - expect(svc.getConnection(conn.id)).toBeUndefined(); + await ref.saveSecret!('device_token', EARNED); + expect(await svc.getConnection(conn.id)).toBeUndefined(); }); }); @@ -414,7 +416,7 @@ describe('validateBaseUrl', () => { const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ baseUrl: 'https://nope.invalid' })); expect(result.success).toBe(false); expect(result.success === false && result.error.code).toBe('ssrf_blocked'); - expect(svc.listConnections(TRIP)).toHaveLength(0); + expect(await svc.listConnections(TRIP)).toHaveLength(0); }); it('strips trailing slashes so the adapter can append its own path', async () => { @@ -434,7 +436,7 @@ describe('upsertConnection required fields', () => { const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ credentials: {} })); expect(result.success).toBe(false); expect(result.success === false && result.error.detail).toContain('api_token'); - expect(svc.listConnections(TRIP)).toHaveLength(0); + expect(await svc.listConnections(TRIP)).toHaveLength(0); }); it('refuses a missing required field that is not a secret either', async () => { @@ -462,12 +464,12 @@ describe('upsertConnection required fields', () => { }); describe('enabledProviderIds', () => { - it('names only the providers an admin switched on, in the order the shelf shows them', () => { + it('names only the providers an admin switched on, in the order the shelf shows them', async () => { // Every document provider ships OFF, so an empty answer on a fresh install // is the correct one and a non-empty one would be a policy change. - expect(svc.enabledProviderIds()).toEqual([]); + expect(await svc.enabledProviderIds()).toEqual([]); testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run(); - expect(svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']); + expect(await svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']); }); }); @@ -477,10 +479,10 @@ describe('recordProbe', () => { // instance can accept uploads; the capabilities describe the provider, not // the state of the credential. const conn = await connect(); - svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' }); - svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null); + await svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' }); + await svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null); - const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow); + const view = await svc.publicConnection((await svc.getConnection(conn.id)) as ConnectionRow); expect(view).toMatchObject({ lastProbeState: 'failed', lastProbeError: '401 Unauthorized', @@ -497,16 +499,16 @@ describe('deleteConnection', () => { baseUrl: 'https://cloud.example.com', credentials: { login_name: 'anna', app_password: 'app-pw' }, }); - const doomedLink = link(doomed.id, { scopeKey: 'tag:1' }); - const keptLink = link(kept.id, { scopeKey: 'folder:1' }); + const doomedLink = await link(doomed.id, { scopeKey: 'tag:1' }); + const keptLink = await link(kept.id, { scopeKey: 'folder:1' }); testDb .prepare('INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)') .run(doomedLink.id, TRIP, 'uid-a'); - svc.deleteConnection(doomed.id); + await svc.deleteConnection(doomed.id); - expect(svc.getConnection(doomed.id)).toBeUndefined(); - expect(svc.listLinks(TRIP).map((l) => l.id)).toEqual([keptLink.id]); + expect(await svc.getConnection(doomed.id)).toBeUndefined(); + expect((await svc.listLinks(TRIP)).map((l) => l.id)).toEqual([keptLink.id]); expect(testDb.prepare('SELECT COUNT(*) AS n FROM document_sync_items').get()).toEqual({ n: 0 }); }); }); @@ -515,7 +517,7 @@ describe('toRef', () => { it('names the row and not only its id, which a backup restored in place hands out again', async () => { const first = await connect(); testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id); - const before = svc.toRef(svc.getConnection(first.id) as ConnectionRow); + const before = svc.toRef(await svc.getConnection(first.id) as ConnectionRow); // What the restore of an older backup does to this table: the row is gone // and the id sequence is back where it stood before the row was made. @@ -532,8 +534,8 @@ describe('toRef', () => { describe('updateLink', () => { it('changes only the fields the patch names', async () => { const conn = await connect(); - const created = link(conn.id, { direction: 'both', conflictPolicy: 'manual' }); - const patched = svc.updateLink(created.id, { direction: 'pull', syncEnabled: false }); + const created = await link(conn.id, { direction: 'both', conflictPolicy: 'manual' }); + const patched = await svc.updateLink(created.id, { direction: 'pull', syncEnabled: false }); expect(patched).toMatchObject({ direction: 'pull', sync_enabled: 0, @@ -544,8 +546,8 @@ describe('updateLink', () => { it('hands back the binding untouched when the patch is empty', async () => { const conn = await connect(); - const created = link(conn.id); - expect(svc.updateLink(created.id, {})).toEqual(created); + const created = await link(conn.id); + expect(await svc.updateLink(created.id, {})).toEqual(created); }); }); @@ -554,7 +556,7 @@ describe('toScopeRef', () => { // The adapters take a ref object rather than positional arguments because // an id and a path swapped compile cleanly and then read the wrong folder. const conn = await connect(); - const created = svc.createLink( + const created = await svc.createLink( TRIP, OWNER, linkInput(conn.id, { scopeKey: 'fileid:437', remoteRootId: '437', remoteRootPath: '/TREK/japan' }), @@ -563,7 +565,7 @@ describe('toScopeRef', () => { const row = created.success ? created.data : ({} as LinkRow); testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id); - expect(svc.toScopeRef(svc.getLink(row.id) as LinkRow)).toEqual({ + expect(svc.toScopeRef(await svc.getLink(row.id) as LinkRow)).toEqual({ linkId: row.id, tripId: TRIP, scopeKey: 'fileid:437', @@ -577,37 +579,37 @@ describe('toScopeRef', () => { describe('createLink', () => { it('returns the binding that already exists rather than making a second one', async () => { const conn = await connect(); - const first = link(conn.id); - const again = svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' })); + const first = await link(conn.id); + const again = await svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' })); expect(again.success && again.data.id).toBe(first.id); - expect(svc.listLinks(TRIP)).toHaveLength(1); + expect(await svc.listLinks(TRIP)).toHaveLength(1); }); it('binds a second scope of the same connection separately', async () => { const conn = await connect(); - const first = link(conn.id, { scopeKey: 'tag:1' }); - const second = link(conn.id, { scopeKey: 'tag:2' }); + const first = await link(conn.id, { scopeKey: 'tag:1' }); + const second = await link(conn.id, { scopeKey: 'tag:2' }); expect(second.id).not.toBe(first.id); - expect(svc.listLinks(TRIP)).toHaveLength(2); + expect(await svc.listLinks(TRIP)).toHaveLength(2); }); it('refuses a connection that belongs to somebody else\'s trip', async () => { const otherTrip = createTrip(testDb, MEMBER, { title: 'Not yours' }).id; const conn = await connect(); - const result = svc.createLink(otherTrip, MEMBER, linkInput(conn.id)); + const result = await svc.createLink(otherTrip, MEMBER, linkInput(conn.id)); expect(result.success).toBe(false); expect(result.success === false && result.error.code).toBe('not_found'); - expect(svc.listLinks(otherTrip)).toHaveLength(0); + expect(await svc.listLinks(otherTrip)).toHaveLength(0); }); it('refuses a connection id that does not exist at all', async () => { - const result = svc.createLink(TRIP, OWNER, linkInput(9999)); + const result = await svc.createLink(TRIP, OWNER, linkInput(9999)); expect(result.success).toBe(false); }); it('stores the anchor the picker sent back with the scope key', async () => { const conn = await connect(); - const result = svc.createLink( + const result = await svc.createLink( TRIP, OWNER, linkInput(conn.id, { remoteRootId: '17', remoteRootPath: '/TREK/japan', remoteLabel: 'Japan 2026' }), @@ -623,7 +625,7 @@ describe('createLink', () => { it('stores NULL for an anchor field the client left out', async () => { // Synology binds by path alone, so its options carry no root id at all. const conn = await connect(); - const result = svc.createLink(TRIP, OWNER, linkInput(conn.id)); + const result = await svc.createLink(TRIP, OWNER, linkInput(conn.id)); expect(result.success && result.data).toMatchObject({ remote_root_id: null, remote_root_path: null }); }); @@ -631,15 +633,15 @@ describe('createLink', () => { // A leaked webhook URL has to identify exactly one binding, so revoking one // cannot be used to poke another. const conn = await connect(); - const first = link(conn.id, { scopeKey: 'tag:1' }); - const second = link(conn.id, { scopeKey: 'tag:2' }); + const first = await link(conn.id, { scopeKey: 'tag:1' }); + const second = await link(conn.id, { scopeKey: 'tag:2' }); expect(first.webhook_token).toBeTruthy(); expect(second.webhook_token).not.toBe(first.webhook_token); - expect(svc.getLinkByToken(first.webhook_token as string)?.id).toBe(first.id); + expect((await svc.getLinkByToken(first.webhook_token as string))?.id).toBe(first.id); const secret = svc.webhookSecret(first); expect(secret).toBeTruthy(); - const view = svc.publicLink(first, 'https://trek.example.com'); + const view = await svc.publicLink(first, 'https://trek.example.com'); expect(view.webhookSecret).toBe(DOCSYNC_SECRET_MASK); expect(JSON.stringify(view)).not.toContain(secret); }); @@ -650,24 +652,24 @@ describe('publicLink', () => { // The client only learns names for the providers that are on, so this is // the one place a binding left behind can still get its name from. const conn = await connect(); - const bound = link(conn.id); + const bound = await link(conn.id); testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run(); - expect(svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); + expect(await svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); }); it('falls back to the provider id for a provider this build does not know', async () => { const conn = await connect(); - const bound = link(conn.id); - expect(svc.publicLink({ ...bound, provider_id: 'dropbox' }, null).providerName).toBe('dropbox'); + const bound = await link(conn.id); + expect((await svc.publicLink({ ...bound, provider_id: 'dropbox' }, null)).providerName).toBe('dropbox'); }); }); describe('deleteLink', () => { it('takes the pairing rows with it and leaves the other binding\'s alone', async () => { const conn = await connect(); - const doomed = link(conn.id, { scopeKey: 'tag:1' }); - const kept = link(conn.id, { scopeKey: 'tag:2' }); + const doomed = await link(conn.id, { scopeKey: 'tag:1' }); + const kept = await link(conn.id, { scopeKey: 'tag:2' }); const insert = testDb.prepare( 'INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)', ); @@ -675,18 +677,18 @@ describe('deleteLink', () => { insert.run(doomed.id, TRIP, 'uid-b'); insert.run(kept.id, TRIP, 'uid-c'); - svc.deleteLink(doomed.id); + await svc.deleteLink(doomed.id); - expect(svc.getLink(doomed.id)).toBeUndefined(); - expect(svc.getLink(kept.id)).toBeDefined(); + expect(await svc.getLink(doomed.id)).toBeUndefined(); + expect(await svc.getLink(kept.id)).toBeDefined(); const rows = testDb.prepare('SELECT link_id FROM document_sync_items').all() as { link_id: number }[]; expect(rows.map((r) => r.link_id)).toEqual([kept.id]); }); it('leaves the connection behind, so unbinding one scope does not log the trip out', async () => { const conn = await connect(); - svc.deleteLink(link(conn.id).id); - expect(svc.getConnection(conn.id)).toBeDefined(); + await svc.deleteLink((await link(conn.id)).id); + expect(await svc.getConnection(conn.id)).toBeDefined(); }); }); @@ -695,15 +697,15 @@ describe('markOrphanedLinks', () => { const credentials = providerId === 'paperless' ? { api_token: TOKEN } : { login_name: 'anna', app_password: 'app-pw' }; const conn = await connect({ providerId, credentials, baseUrl: `https://${providerId}.example.com` }, ownerUserId); - return link(conn.id, { scopeKey: `scope:${providerId}` }); + return await link(conn.id, { scopeKey: `scope:${providerId}` }); } it('disables the binding whose credential owner left the trip', async () => { const orphan = await bind(MEMBER, 'paperless'); testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); - expect(svc.markOrphanedLinks()).toBe(1); - expect(svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 }); + expect(await svc.markOrphanedLinks()).toBe(1); + expect(await svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 }); }); it('counts the trip owner as being on the trip, although no membership row says so', async () => { @@ -711,8 +713,8 @@ describe('markOrphanedLinks', () => { // owner would orphan every binding the trip admin created, which is most // of them. const mine = await bind(OWNER, 'paperless'); - expect(svc.markOrphanedLinks()).toBe(0); - expect(svc.getLink(mine.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 }); + expect(await svc.markOrphanedLinks()).toBe(0); + expect(await svc.getLink(mine.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 }); }); it('leaves a still-valid binding alone while orphaning the one beside it', async () => { @@ -720,25 +722,25 @@ describe('markOrphanedLinks', () => { const kept = await bind(OWNER, 'nextcloud'); testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); - expect(svc.markOrphanedLinks()).toBe(1); - expect(svc.getLink(orphan.id)?.last_sync_state).toBe('orphaned'); - expect(svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 }); + expect(await svc.markOrphanedLinks()).toBe(1); + expect((await svc.getLink(orphan.id))?.last_sync_state).toBe('orphaned'); + expect(await svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 }); }); it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => { await bind(MEMBER, 'paperless'); testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); - expect(svc.markOrphanedLinks()).toBe(1); - expect(svc.markOrphanedLinks()).toBe(0); + expect(await svc.markOrphanedLinks()).toBe(1); + expect(await svc.markOrphanedLinks()).toBe(0); }); it('does not touch a binding the user had already switched off', async () => { const paused = await bind(MEMBER, 'paperless'); - svc.updateLink(paused.id, { syncEnabled: false }); + await svc.updateLink(paused.id, { syncEnabled: false }); testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); - expect(svc.markOrphanedLinks()).toBe(0); - expect(svc.getLink(paused.id)?.last_sync_state).toBe('never'); + expect(await svc.markOrphanedLinks()).toBe(0); + expect((await svc.getLink(paused.id))?.last_sync_state).toBe('never'); }); }); @@ -749,39 +751,39 @@ describe('isOrphaned', () => { it('answers yes for a paused binding whose owner left, which the sweep never marks', async () => { const paused = await bind(MEMBER); - svc.updateLink(paused.id, { syncEnabled: false }); + await svc.updateLink(paused.id, { syncEnabled: false }); testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); - expect(svc.markOrphanedLinks()).toBe(0); - expect(svc.isOrphaned(svc.getLink(paused.id)!)).toBe(true); + expect(await svc.markOrphanedLinks()).toBe(0); + expect(await svc.isOrphaned((await svc.getLink(paused.id))!)).toBe(true); }); it('answers yes for a marked binding, and no while the owner is on the trip', async () => { const kept = await bind(OWNER); - expect(svc.isOrphaned(kept)).toBe(false); + expect(await svc.isOrphaned(kept)).toBe(false); testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(kept.id); - expect(svc.isOrphaned(svc.getLink(kept.id)!)).toBe(true); + expect(await svc.isOrphaned((await svc.getLink(kept.id))!)).toBe(true); }); it('leaves a connection that is gone to the run, which reports it as not found', async () => { const bound = await bind(OWNER); - expect(svc.isOrphaned({ ...bound, connection_id: 999999 })).toBe(false); + expect(await svc.isOrphaned({ ...bound, connection_id: 999999 })).toBe(false); }); }); describe('switching an orphaned binding back on', () => { async function orphan(): Promise { - const bound = link((await connect({}, MEMBER)).id); + const bound = await link((await connect({}, MEMBER)).id); testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); - svc.markOrphanedLinks(); + await svc.markOrphanedLinks(); return bound; } it('keeps it off while its owner is still gone, and changes the rest of the patch', async () => { const bound = await orphan(); - const res = svc.updateLink(bound.id, { syncEnabled: true, direction: 'pull' }); + const res = await svc.updateLink(bound.id, { syncEnabled: true, direction: 'pull' }); expect(res).toMatchObject({ sync_enabled: 0, last_sync_state: 'orphaned', direction: 'pull' }); }); @@ -790,9 +792,9 @@ describe('switching an orphaned binding back on', () => { const bound = await orphan(); testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER); - const res = svc.updateLink(bound.id, { syncEnabled: true }); + const res = await svc.updateLink(bound.id, { syncEnabled: true }); expect(res).toMatchObject({ sync_enabled: 1, last_sync_state: 'never' }); - expect(svc.isOrphaned(res!)).toBe(false); + expect(await svc.isOrphaned(res!)).toBe(false); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts index efca6c23f..e38601a5c 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts @@ -78,6 +78,7 @@ import type { DocsyncLinkDto, } from '../../../../src/nest/doc-sync/doc-sync.dto'; import type { User } from '../../../../src/types'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; const CAPS = { push: 'webhook-self-registered' as const, @@ -124,9 +125,11 @@ const sync = { const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]); const dbs = new DatabaseService(testDb); -const config = new DocSyncConfigService(dbs, registry); const realtime = { broadcast: vi.fn() }; -const controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs, realtime as unknown as RealtimeService); +// Built in beforeAll: DocSyncConfigService now takes a UnitOfWork, and +// createTestUnitOfWork is async — module-scope construction cannot await it. +let config: DocSyncConfigService; +let controller: DocSyncController; /** Only what publicOrigin reads: header lookup plus the connection's own scheme. */ function makeReq(headers: Record = {}, protocol = 'http'): Request { @@ -178,7 +181,9 @@ let admin: User; let tripId: number; let otherTripId: number; -beforeAll(() => { +beforeAll(async () => { + config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb)); + controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs, realtime as unknown as RealtimeService); createTables(testDb); runMigrations(testDb); const o = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user; @@ -233,8 +238,8 @@ describe('who may change a trip binding', () => { }); describe('providers', () => { - it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', () => { - const rows = controller.providers() as Array<{ id: string; available: boolean }>; + it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', async () => { + const rows = (await controller.providers()) as Array<{ id: string; available: boolean }>; const byId = Object.fromEntries(rows.map((r) => [r.id, r.available])); // papra is switched on in the DB, but no adapter is registered in this suite. expect(byId.papra).toBe(false); @@ -242,8 +247,8 @@ describe('providers', () => { expect(byId.nextcloud).toBe(true); }); - it('hands the form flags to the client as booleans rather than the 0/1 the column stores', () => { - const rows = controller.providers() as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>; + it('hands the form flags to the client as booleans rather than the 0/1 the column stores', async () => { + const rows = (await controller.providers()) as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>; const fields = rows.find((r) => r.id === 'paperless')!.fields; const token = fields.find((f) => f.field_key === 'api_token')!; expect(token.secret).toBe(true); @@ -262,7 +267,7 @@ describe('storing a connection', () => { expect(res.secrets.api_token).not.toContain('super-secret'); - const listed = controller.listConnections(String(tripId)) as Array<{ id: number; secrets: Record }>; + const listed = (await controller.listConnections(String(tripId))) as Array<{ id: number; secrets: Record }>; expect(listed).toHaveLength(1); expect(listed[0].id).toBe(res.id); // The list is what the form renders from, so the secret must not survive @@ -273,7 +278,7 @@ describe('storing a connection', () => { it('refuses a base URL the SSRF guard turns down', async () => { const err = await thrown(() => controller.upsertConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' }))); expect(err.getStatus()).toBe(400); - expect(config.listConnections(tripId)).toHaveLength(0); + expect(await config.listConnections(tripId)).toHaveLength(0); }); it('refuses to point the stored secret at another server when the form leaves it blank', async () => { @@ -285,8 +290,8 @@ describe('storing a connection', () => { controller.upsertConnection(String(tripId), admin, connBody({ baseUrl: 'https://elsewhere.example', credentials: {} })), ); expect(err.getStatus()).toBe(400); - expect(config.getConnection(conn.id)!.base_url).toBe('https://paperless.example.com'); - expect(config.getConnection(conn.id)!.owner_user_id).toBe(Number(owner.id)); + expect((await config.getConnection(conn.id))!.base_url).toBe('https://paperless.example.com'); + expect((await config.getConnection(conn.id))!.owner_user_id).toBe(Number(owner.id)); }); it('refuses a provider the instance admin has not switched on, and names it', async () => { @@ -372,14 +377,14 @@ describe('probing form values', () => { it('writes a successful probe onto the stored connection, so the status panel has something to show', async () => { const conn = await storedPaperless(); await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto); - const row = config.getConnection(conn.id)!; + const row = (await config.getConnection(conn.id))!; expect(row.last_probe_state).toBe('ok'); expect(JSON.parse(row.capabilities!)).toMatchObject({ push: 'webhook-self-registered' }); }); it('leaves no probe record behind when the form values belong to no stored connection yet', async () => { await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto); - expect(config.listConnections(tripId)).toHaveLength(0); + expect(await config.listConnections(tripId)).toHaveLength(0); expect(paperless.probe.mock.calls[0][0]).toMatchObject({ connectionId: 0, secrets: { api_token: 'first-try' } }); }); }); @@ -395,9 +400,9 @@ describe('a secret the adapter earned itself', () => { credentials: { username: 'anna', password: 'nas-pw' }, }); const created = (await controller.upsertConnection(String(tripId), owner, nas)) as { id: number }; - config.saveEarnedSecret(created.id, 'device_token', EARNED); + await config.saveEarnedSecret(created.id, 'device_token', EARNED); - const listed = JSON.stringify(controller.listConnections(String(tripId))); + const listed = JSON.stringify(await controller.listConnections(String(tripId))); const edited = JSON.stringify( await controller.upsertConnection(String(tripId), owner, { ...nas, credentials: { username: 'anna', password: 'rotated' } }), ); @@ -405,12 +410,12 @@ describe('a secret the adapter earned itself', () => { expect(payload).not.toContain('DEVICE-SECRET'); expect(payload).not.toContain('device_token'); } - expect(config.toRef(config.getConnection(created.id)!).secrets.device_token).toBe(EARNED); + expect(config.toRef((await config.getConnection(created.id))!).secrets.device_token).toBe(EARNED); }); it('reaches a probe of the saved form, which gets no way to write', async () => { const conn = await storedPaperless(); - config.saveEarnedSecret(conn.id, 'device_token', EARNED); + await config.saveEarnedSecret(conn.id, 'device_token', EARNED); const res = await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto); const ref = paperless.probe.mock.calls[0][0]; @@ -446,7 +451,7 @@ describe('removing a connection', () => { ); await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true }); - expect(config.getConnection(conn.id)).toBeUndefined(); + expect(await config.getConnection(conn.id)).toBeUndefined(); expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy(); }); @@ -469,8 +474,8 @@ describe('removing a connection', () => { expect(paperless.unregisterWebhook.mock.calls.map((c) => c[1]).sort()).toEqual(['sub-7', 'sub-8']); expect(paperless.unregisterWebhook.mock.calls[0][0]).toMatchObject({ connectionId: conn.id, secrets: { api_token: 'stored-token' } }); - expect(config.getConnection(conn.id)).toBeUndefined(); - expect(config.getLink(first.id)).toBeUndefined(); + expect(await config.getConnection(conn.id)).toBeUndefined(); + expect(await config.getLink(first.id)).toBeUndefined(); }); it('still unbinds when the provider refuses to take a subscription down', async () => { @@ -479,14 +484,14 @@ describe('removing a connection', () => { paperless.unregisterWebhook.mockResolvedValueOnce({ success: false, error: { code: 'unreachable' } } as never); await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true }); - expect(config.getConnection(conn.id)).toBeUndefined(); + expect(await config.getConnection(conn.id)).toBeUndefined(); }); it('refuses a connection that belongs to a different trip', async () => { const conn = await storedPaperless(); const err = await thrown(() => controller.deleteConnection(String(otherTripId), String(conn.id), admin)); expect(err.getStatus()).toBe(404); - expect(config.getConnection(conn.id)).toBeTruthy(); + expect(await config.getConnection(conn.id)).toBeTruthy(); expect(realtime.broadcast).not.toHaveBeenCalled(); }); @@ -498,14 +503,14 @@ describe('removing a connection', () => { credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' }, })); if ('error' in cloud) throw new Error(`fixture failed: ${cloud.error.code}`); - const bind = (connectionId: number, scopeKey: string) => { - const res = config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey })); + const bind = async (connectionId: number, scopeKey: string) => { + const res = await config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey })); if ('error' in res) throw new Error(`fixture failed: ${res.error.code}`); return res.data.id; }; - const first = bind(conn.id, 'tag:1'); - const second = bind(conn.id, 'tag:2'); - bind(cloud.data.id, 'folder:1'); + const first = await bind(conn.id, 'tag:1'); + const second = await bind(conn.id, 'tag:2'); + await bind(cloud.data.id, 'folder:1'); await controller.deleteConnection(String(tripId), String(conn.id), owner); @@ -593,7 +598,7 @@ describe('creating a binding', () => { makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'trek.example' }), )) as { id: number }; - const row = config.getLink(link.id)!; + const row = (await config.getLink(link.id))!; expect(row.webhook_subscription_id).toBe('sub-7'); expect(paperless.registerWebhook.mock.calls[0][2]).toBe(`https://trek.example/api/docsync/webhook/${row.webhook_token}`); // The secret handed to the provider is the plaintext one, not the stored blob. @@ -611,7 +616,7 @@ describe('creating a binding', () => { makeReq({ 'x-forwarded-host': 'trek.example' }), )) as { id: number; syncEnabled: boolean }; - const row = config.getLink(link.id)!; + const row = (await config.getLink(link.id))!; expect(row.webhook_subscription_id).toBeNull(); expect(row.sync_enabled).toBe(1); }); @@ -675,7 +680,7 @@ describe('changing a binding', () => { const conn = await storedPaperless(); const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number }; - const res = controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq()) as { + const res = (await controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq())) as { syncEnabled: boolean; direction: string; remoteLabel: string; @@ -691,7 +696,7 @@ describe('changing a binding', () => { const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number }; const err = await thrown(() => controller.updateLink(String(otherTripId), String(created.id), admin, { syncEnabled: false }, makeReq())); expect(err.getStatus()).toBe(404); - expect(config.getLink(created.id)!.sync_enabled).toBe(1); + expect((await config.getLink(created.id))!.sync_enabled).toBe(1); }); }); @@ -775,14 +780,14 @@ describe('the document list', () => { it('carries the local file name, so the list reads as documents rather than as ids', async () => { await seedItems(); - const rows = controller.items(String(tripId)) as Array<{ trek_doc_uid: string; file_name: string | null }>; + const rows = (await controller.items(String(tripId))) as Array<{ trek_doc_uid: string; file_name: string | null }>; expect(rows).toHaveLength(2); expect(rows.find((r) => r.trek_doc_uid === 'uid-1')!.file_name).toBe('boarding.pdf'); }); it('narrows to one state when asked, and never leaves the trip', async () => { await seedItems(); - const rows = controller.items(String(tripId), 'conflict') as Array<{ trek_doc_uid: string }>; + const rows = (await controller.items(String(tripId), 'conflict')) as Array<{ trek_doc_uid: string }>; expect(rows.map((r) => r.trek_doc_uid)).toEqual(['uid-2']); }); }); @@ -817,7 +822,7 @@ describe('removing a binding', () => { expect(res).toEqual({ success: true, documentsKept: true }); expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7'); - expect(config.getLink(created.id)).toBeUndefined(); + expect(await config.getLink(created.id)).toBeUndefined(); expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy(); }); @@ -839,7 +844,7 @@ describe('removing a binding', () => { realtime.broadcast.mockClear(); const err = await thrown(() => controller.deleteLink(String(otherTripId), String(created.id), admin)); expect(err.getStatus()).toBe(404); - expect(config.getLink(created.id)).toBeTruthy(); + expect(await config.getLink(created.id)).toBeTruthy(); expect(realtime.broadcast).not.toHaveBeenCalled(); }); }); @@ -848,30 +853,30 @@ describe('the callback origin a provider is given', () => { async function linkedTrip() { const conn = await storedPaperless(); const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number }; - return config.getLink(created.id)!; + return (await config.getLink(created.id))!; } it('builds the callback URL from the forwarded proto and host', async () => { const row = await linkedTrip(); - const [link] = controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' })) as Array<{ webhookUrl: string }>; + const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>; expect(link.webhookUrl).toBe(`https://docs.example/api/docsync/webhook/${row.webhook_token}`); }); it('takes the first hop when a proxy chain appends its own protocol', async () => { await linkedTrip(); - const [link] = controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' })) as Array<{ webhookUrl: string }>; + const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>; expect(link.webhookUrl).toMatch(/^https:\/\/docs\.example\//); }); it('falls back to the Host header and the connection scheme when nothing is forwarded', async () => { await linkedTrip(); - const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http')) as Array<{ webhookUrl: string }>; + const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http'))) as Array<{ webhookUrl: string }>; expect(link.webhookUrl).toMatch(/^http:\/\/trek\.lan:3001\//); }); it('offers no callback URL when a proxy strips the host', async () => { await linkedTrip(); - const [link] = controller.listLinks(String(tripId), makeReq()) as Array<{ webhookUrl: string | null }>; + const [link] = (await controller.listLinks(String(tripId), makeReq())) as Array<{ webhookUrl: string | null }>; expect(link.webhookUrl).toBeNull(); }); @@ -879,15 +884,15 @@ describe('the callback origin a provider is given', () => { // Synology takes no webhook. Showing the address with "paste this into // your provider" next to it promised something there was nowhere to do. const row = await linkedTrip(); - config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'none' }); - const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.example' })) as Array<{ webhookUrl: string | null }>; + await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'none' }); + const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>; expect(link.webhookUrl).toBeNull(); }); it('keeps offering it for a store the probe found able to take one', async () => { const row = await linkedTrip(); - config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'webhook-manual' }); - const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.example' })) as Array<{ webhookUrl: string | null }>; + await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'webhook-manual' }); + const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>; expect(link.webhookUrl).toBe(`http://trek.example/api/docsync/webhook/${row.webhook_token}`); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts index ee3effefb..347b098de 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts @@ -106,48 +106,48 @@ function makeJob(over: Partial = {}) { beforeEach(() => vi.clearAllMocks()); describe('DocSyncJob bootstrap', () => { - it('schedules nothing, reads nothing and logs nothing while the registrar is off', () => { + it('schedules nothing, reads nothing and logs nothing while the registrar is off', async () => { // The test gate. A job that registered past it would have every suite boot // start polling whatever document store sits in the fixture database. const { job, registrar, db } = makeJob({ registrarEnabled: false }); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).not.toHaveBeenCalled(); expect(db.get).not.toHaveBeenCalled(); expect(log.logInfo).not.toHaveBeenCalled(); }); - it('registers one cron under a name of its own, on the minute', () => { + it('registers one cron under a name of its own, on the minute', async () => { // Every minute, with the tick deciding whether it is due. Baking the // interval into the expression at bootstrap meant a changed setting did // nothing until a restart, which is the opposite of what this job's own // comment promises, and nothing re-registers it (auto-backup has a start() // its settings save calls; this has no such path). const { job, registrar } = makeJob(); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('docsync', '* * * * *', expect.any(Function)); expect(log.logInfo).toHaveBeenCalledWith('Document sync: polling every 300s'); }); - it('reads the interval from its own app_settings key', () => { + it('reads the interval from its own app_settings key', async () => { const { job, db } = makeJob({ interval: '600' }); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL); }); it('hands the registrar the tick itself, so a fired cron reaches the sync', async () => { const { job, sync, takeTick } = makeJob({ links: [link(1)] }); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); const tick = takeTick(); expect(tick).toBeTypeOf('function'); await tick?.(); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); - it('does not decide at bootstrap whether the addon is on', () => { + it('does not decide at bootstrap whether the addon is on', async () => { // Asking here would freeze the answer for the life of the process, and the // bug would read as "the documents toggle needs a restart". const { job, addons } = makeJob(); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(addons.isAddonEnabled).not.toHaveBeenCalled(); }); }); @@ -421,9 +421,10 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => { const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService; const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]); - const config = new DocSyncConfigService(dbs, registry); - // Built in beforeAll: DocSyncService now takes a UnitOfWork, and - // createTestUnitOfWork is async — module-scope construction cannot await it. + // Built in beforeAll: DocSyncConfigService and DocSyncService now take a + // UnitOfWork, and createTestUnitOfWork is async — module-scope construction + // cannot await it. + let config: DocSyncConfigService; let service: DocSyncService; const registrar = { isEnabled: () => false } as unknown as CronRegistrarService; /** A job of its own per pass, so the interval never decides whether a tick runs. */ @@ -434,6 +435,7 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => { const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id); beforeAll(async () => { + config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb)); service = new DocSyncService( dbs, config, registry, {} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs), diff --git a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts index 444027b0b..d3b962032 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts @@ -196,7 +196,7 @@ let config: DocSyncConfigService; // ── Row builders ───────────────────────────────────────────────────────────── let scopeSeq = 0; -function makeLink(over: { +async function makeLink(over: { direction?: string; deletePolicy?: string; conflictPolicy?: string; @@ -205,7 +205,7 @@ function makeLink(over: { nextAttemptAt?: string | null; lastSyncAt?: string | null; providerId?: string; -} = {}): LinkRow { +} = {}): Promise { scopeSeq += 1; const info = testDb .prepare( @@ -223,7 +223,7 @@ function makeLink(over: { testDb.prepare('UPDATE trip_document_links SET last_sync_at = ? WHERE id = ?') .run(over.lastSyncAt, Number(info.lastInsertRowid)); } - return config.getLink(Number(info.lastInsertRowid)); + return (await config.getLink(Number(info.lastInsertRowid)))!; } let uidSeq = 0; @@ -321,7 +321,7 @@ describe('DocSyncService', () => { const dbs = new DatabaseService(testDb); const registry = new DocumentProviderRegistry([provider as unknown as DocumentProvider]); - config = new DocSyncConfigService(dbs, registry); + config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb)); service = new DocSyncService( dbs, config, @@ -355,66 +355,66 @@ describe('DocSyncService', () => { }); describe('dueLinks', () => { - it('offers only bindings that are switched on, uncircuited and actually due', () => { - const dueNow = makeLink(); - const overdue = makeLink({ nextAttemptAt: sqlTime('-1 hour') }); - makeLink({ nextAttemptAt: sqlTime('+1 hour') }); - makeLink({ syncEnabled: 0 }); - makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER }); + it('offers only bindings that are switched on, uncircuited and actually due', async () => { + const dueNow = await makeLink(); + const overdue = await makeLink({ nextAttemptAt: sqlTime('-1 hour') }); + await makeLink({ nextAttemptAt: sqlTime('+1 hour') }); + await makeLink({ syncEnabled: 0 }); + await makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER }); - const ids = service.dueLinks().map((l) => l.id).sort((a, b) => a - b); + const ids = (await service.dueLinks()).map((l) => l.id).sort((a, b) => a - b); expect(ids).toEqual([dueNow.id, overdue.id].sort((a, b) => a - b)); }); - it('puts the longest-waiting binding first, so the limit cannot starve the newer ones', () => { + it('puts the longest-waiting binding first, so the limit cannot starve the newer ones', async () => { // A successful run clears next_attempt_at, so on a healthy instance every // binding ties on the first sort key. Without a second key SQLite answers // in insertion order and the same oldest bindings win every tick: found on // a dev database with 170 of them, where a binding created minutes earlier // had never been synced while the first twenty ran over and over. - const recent = makeLink({ lastSyncAt: sqlTime('-1 minute') }); - const stale = makeLink({ lastSyncAt: sqlTime('-2 hours') }); - const never = makeLink({ lastSyncAt: null }); + const recent = await makeLink({ lastSyncAt: sqlTime('-1 minute') }); + const stale = await makeLink({ lastSyncAt: sqlTime('-2 hours') }); + const never = await makeLink({ lastSyncAt: null }); - expect(service.dueLinks().map((l) => l.id)).toEqual([never.id, stale.id, recent.id]); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([never.id, stale.id, recent.id]); }); - it('serves ordinary bindings before one that is only due again after a backoff', () => { + it('serves ordinary bindings before one that is only due again after a backoff', async () => { // COALESCE(next_attempt_at, '1970-01-01') is what orders these: a binding // with no backoff counts as due since the epoch and therefore goes first, // while one whose backoff has merely expired sorts by when it expired. // Normal work ahead of a retry, which is the right way round. - const backedOff = makeLink({ nextAttemptAt: sqlTime('-1 hour'), lastSyncAt: sqlTime('-1 minute') }); - const never = makeLink({ lastSyncAt: null }); + const backedOff = await makeLink({ nextAttemptAt: sqlTime('-1 hour'), lastSyncAt: sqlTime('-1 minute') }); + const never = await makeLink({ lastSyncAt: null }); - expect(service.dueLinks().map((l) => l.id)).toEqual([never.id, backedOff.id]); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([never.id, backedOff.id]); }); - it('honours the limit', () => { - for (let i = 0; i < 25; i += 1) makeLink({ lastSyncAt: null }); - expect(service.dueLinks().length).toBe(20); - expect(service.dueLinks(5).length).toBe(5); + it('honours the limit', async () => { + for (let i = 0; i < 25; i += 1) await makeLink({ lastSyncAt: null }); + expect((await service.dueLinks()).length).toBe(20); + expect((await service.dueLinks(5)).length).toBe(5); }); - it('lets a binding back in one failure before the circuit opens', () => { - const nearly = makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER - 1 }); - expect(service.dueLinks().map((l) => l.id)).toEqual([nearly.id]); + it('lets a binding back in one failure before the circuit opens', async () => { + const nearly = await makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER - 1 }); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([nearly.id]); }); - it('leaves out an orphaned binding even when Sync automatically was switched back on', () => { + it('leaves out an orphaned binding even when Sync automatically was switched back on', async () => { // It would never run, so it would never get a last_sync_at and would take // one of the slots on every tick. - const orphaned = makeLink({ lastSyncAt: null }); + const orphaned = await makeLink({ lastSyncAt: null }); testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(orphaned.id); - const live = makeLink({ lastSyncAt: sqlTime('-1 minute') }); + const live = await makeLink({ lastSyncAt: sqlTime('-1 minute') }); - expect(service.dueLinks().map((l) => l.id)).toEqual([live.id]); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([live.id]); }); }); describe('syncLink', () => { it('answers busy instead of running the same binding twice at once', async () => { - const link = makeLink(); + const link = await makeLink(); let release: () => void; provider.resolveScope.mockImplementationOnce( () => new Promise((resolve) => { release = () => resolve(ok(SCOPE)); }), @@ -437,7 +437,7 @@ describe('DocSyncService', () => { }); it('parks a binding whose scope is gone as scope_lost rather than as a failure', async () => { - const link = makeLink(); + const link = await makeLink(); provider.resolveScope.mockResolvedValueOnce(fail('scope_missing')); const res = await service.syncLink(link); @@ -451,7 +451,7 @@ describe('DocSyncService', () => { }); it('treats a not-found scope the same as a missing one', async () => { - const link = makeLink(); + const link = await makeLink(); provider.resolveScope.mockResolvedValueOnce(fail('not_found')); const res = await service.syncLink(link); @@ -461,7 +461,7 @@ describe('DocSyncService', () => { }); it('asks for fresh credentials when the listing comes back unauthorized', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(fail('unauthorized', 401)); const res = await service.syncLink(link); @@ -473,7 +473,7 @@ describe('DocSyncService', () => { }); it('does not mistake an unreachable instance for a credential problem', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(fail('unreachable')); const res = await service.syncLink(link); @@ -490,7 +490,7 @@ describe('DocSyncService', () => { * because a listing that cannot be trusted cannot be trusted in pieces. */ it('abandons the entire run when most known documents vanish from one listing', async () => { - const link = makeLink(); + const link = await makeLink(); const itemIds = Array.from({ length: 10 }, (_, i) => seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, state: 'synced' })); makeFile({ name: 'local-only.pdf' }); @@ -524,7 +524,7 @@ describe('DocSyncService', () => { * the bytes are never asked for. */ it('refuses a document whose extension TREK would serve inline', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-svg', name: 'floorplan.svg' }), remoteDoc({ remoteId: 'r-html', name: 'itinerary.html' }), @@ -542,7 +542,7 @@ describe('DocSyncService', () => { }); it('refuses a document larger than an upload would be allowed to be', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-big', name: 'scan.pdf', size: MAX_FILE_SIZE + 1 }), ]))); @@ -561,7 +561,7 @@ describe('DocSyncService', () => { * that says why. */ it('never uploads a document whose type the provider has said it will not take', async () => { - const link = makeLink(); + const link = await makeLink(); capabilities = { ...BASE_CAPABILITIES, acceptedMimeTypes: ['application/pdf'] }; const fileId = makeFile({ name: 'sunset.png', mime: 'image/png' }); @@ -581,7 +581,7 @@ describe('DocSyncService', () => { * re-uploaded, and bounce forever. */ it('does not pull back the bytes it uploaded itself', async () => { - const link = makeLink(); + const link = await makeLink(); makeFile({ name: 'boarding.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([]))); @@ -612,7 +612,7 @@ describe('DocSyncService', () => { // has them. Two trip files with the same content therefore push to ONE // document, and the unique index on (link_id, remote_id) turned that // into a constraint error that aborted this run and every run after it. - const link = makeLink(); + const link = await makeLink(); makeFile({ name: 'boarding.pdf' }); makeFile({ name: 'boarding-copy.pdf', storageKey: 'key-copy' }); provider.list.mockResolvedValue(ok(listing([]))); @@ -636,7 +636,7 @@ describe('DocSyncService', () => { }); it('counts a failure and puts the binding off until later', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(fail('unreachable')); await service.syncLink(link); @@ -648,12 +648,12 @@ describe('DocSyncService', () => { expect(after.in_future).toBe(1); provider.list.mockResolvedValueOnce(fail('unreachable')); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(linkRow(link.id).failure_count).toBe(2); }); it('clears the failure count and the wait as soon as a run succeeds', async () => { - const link = makeLink({ failureCount: 3, nextAttemptAt: sqlTime('+1 hour') }); + const link = await makeLink({ failureCount: 3, nextAttemptAt: sqlTime('+1 hour') }); const res = await service.syncLink(link); @@ -671,7 +671,7 @@ describe('DocSyncService', () => { * Paperless. */ it('leaves chat and note attachments out of the upload entirely', async () => { - const link = makeLink(); + const link = await makeLink(); const messageId = Number(testDb .prepare("INSERT INTO collab_messages (trip_id, user_id, text) VALUES (?, ?, 'hi')") .run(tripId, ownerId).lastInsertRowid); @@ -696,7 +696,7 @@ describe('DocSyncService', () => { * share answers with an empty listing, and a trip is not a cache. */ it('records a document that vanished upstream without removing anything locally', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'boarding.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); @@ -711,7 +711,7 @@ describe('DocSyncService', () => { }); it('moves a deleted document to the provider recycle bin only when the binding says trash', async () => { - const trashing = makeLink({ deletePolicy: 'trash' }); + const trashing = await makeLink({ deletePolicy: 'trash' }); const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); const itemId = seedItem(trashing, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); @@ -720,7 +720,7 @@ describe('DocSyncService', () => { expect(provider.trash).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1'); expect(itemRow(itemId).state).toBe('local_deleted'); - const unlinking = makeLink({ deletePolicy: 'unlink' }); + const unlinking = await makeLink({ deletePolicy: 'unlink' }); const otherFileId = makeFile({ name: 'other.pdf', storageKey: 'key-other', deletedAt: '2026-09-18 08:00:00' }); seedItem(unlinking, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId: otherFileId, state: 'synced' }); provider.trash.mockClear(); @@ -730,7 +730,7 @@ describe('DocSyncService', () => { }); it('carries a rename made in TREK over to the provider', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'new.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', @@ -746,7 +746,7 @@ describe('DocSyncService', () => { }); it('carries a rename made at the provider over to the trip file', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'old.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', @@ -762,7 +762,7 @@ describe('DocSyncService', () => { }); it('hands a document both sides renamed to a person instead of picking one', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'mine.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', @@ -774,11 +774,11 @@ describe('DocSyncService', () => { expect(res.conflicts).toBe(1); expect(provider.rename).not.toHaveBeenCalled(); expect(itemRow(itemId).state).toBe('conflict'); - expect(service.issues(tripId).map((r) => r.id)).toContain(itemId); + expect((await service.issues(tripId)).map((r) => r.id)).toContain(itemId); }); it('settles a double rename TREK wins with a rename, not a second upload', async () => { - const link = makeLink({ conflictPolicy: 'trek_wins' }); + const link = await makeLink({ conflictPolicy: 'trek_wins' }); const fileId = makeFile({ name: 'mine.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', @@ -793,7 +793,7 @@ describe('DocSyncService', () => { expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_name: 'mine.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'mine.pdf', remoteVersion: 'v2' })]))); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.rename).toHaveBeenCalledTimes(1); expect(fileRows().map((f) => f.original_name)).toEqual(['mine.pdf']); @@ -805,7 +805,7 @@ describe('DocSyncService', () => { * for another run. */ it('takes a copy on record as missing off the list when it is back under a new name', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'old.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', @@ -817,11 +817,11 @@ describe('DocSyncService', () => { expect(fileRows()[0].original_name).toBe('new-upstream.pdf'); expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new-upstream.pdf' }); - expect(service.issues(tripId)).toEqual([]); + expect(await service.issues(tripId)).toEqual([]); }); it('does the same when the name TREK gave it meanwhile is carried upstream', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'new.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', @@ -841,7 +841,7 @@ describe('DocSyncService', () => { * become a `trip_files` row, and the half-written spool file goes with it. */ it('cuts off a download that outgrows the cap the listing promised to stay under', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r-lying', name: 'scan.pdf', size: null })]))); provider.fetch.mockResolvedValueOnce(ok({ body: Readable.from([Buffer.allocUnsafe(MAX_FILE_SIZE + 1)]), @@ -859,7 +859,7 @@ describe('DocSyncService', () => { }); it('turns a failed download into a visible error row rather than losing it', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); @@ -872,7 +872,7 @@ describe('DocSyncService', () => { expect(row.attempts).toBe(1); expect(row.file_id).toBeNull(); expect(fileRows()).toHaveLength(0); - expect(service.issues(tripId)).toHaveLength(1); + expect(await service.issues(tripId)).toHaveLength(1); }); it('keeps a failed download visible as an error instead of booking it as synced', async () => { @@ -880,7 +880,7 @@ describe('DocSyncService', () => { // next run saw no change, produced a `touch`, and touch lifted `error` to // `synced`. The document then had no bytes in TREK, was gone from the // issues list, and nothing ever fetched it again. - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValue(ok(listing([remoteDoc()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); @@ -890,11 +890,11 @@ describe('DocSyncService', () => { const row = itemRows()[0]; expect(row.state).toBe('error'); expect(row.file_id).toBeNull(); - expect(service.issues(tripId)).toHaveLength(1); + expect(await service.issues(tripId)).toHaveLength(1); }); it('creates no trip file when storage refuses the finished download', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc()]))); (storage.put as unknown as ReturnType).mockRejectedValueOnce(new Error('disk full')); @@ -906,7 +906,7 @@ describe('DocSyncService', () => { }); it('keeps the document anchor when an upload fails, so the retry is not a second document', async () => { - const link = makeLink(); + const link = await makeLink(); makeFile({ name: 'boarding.pdf' }); provider.push.mockResolvedValueOnce(fail('quota_exceeded')); @@ -920,7 +920,7 @@ describe('DocSyncService', () => { }); it('refuses an upload the provider has told TREK is over its size limit', async () => { - const link = makeLink(); + const link = await makeLink(); capabilities = { ...BASE_CAPABILITIES, maxUploadBytes: 10 }; makeFile({ name: 'boarding.pdf' }); @@ -931,7 +931,7 @@ describe('DocSyncService', () => { }); it('records an upload whose bytes are missing from storage instead of throwing', async () => { - const link = makeLink(); + const link = await makeLink(); makeFile({ name: 'boarding.pdf' }); (storage.getStream as unknown as ReturnType).mockRejectedValueOnce(new Error('gone')); @@ -947,7 +947,7 @@ describe('DocSyncService', () => { * the instance waits. The rest is simply left for the next run. */ it('stops after the per-run transfer budget and leaves the rest for next time', async () => { - const link = makeLink(); + const link = await makeLink(); const many = Array.from({ length: MAX_TRANSFERS_PER_RUN + 5 }, (_, i) => remoteDoc({ remoteId: `r${i}`, name: `doc-${i}.pdf` })); provider.list.mockResolvedValueOnce(ok(listing(many))); @@ -960,7 +960,7 @@ describe('DocSyncService', () => { }); it('records an unexpected crash as a failed run instead of letting it escape', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockImplementationOnce(() => { throw new Error('adapter blew up'); }); const res = await service.syncLink(link); @@ -971,11 +971,11 @@ describe('DocSyncService', () => { }); it('fails a binding for a provider this build does not have', async () => { - const link = makeLink(); + const link = await makeLink(); testDb.prepare("UPDATE trip_document_links SET provider_id = 'papra' WHERE id = ?").run(link.id); switchProvider('papra', true); - const res = await service.syncLink(config.getLink(link.id)); + const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('failed'); expect(res.errorCode).toBe('provider_error'); @@ -994,7 +994,7 @@ describe('DocSyncService', () => { */ describe('an administrator switch', () => { it('lets a binding run while its provider and the addon are on', async () => { - const link = makeLink(); + const link = await makeLink(); const res = await service.syncLink(link); @@ -1003,28 +1003,28 @@ describe('DocSyncService', () => { expect(addons.isAddonEnabled).toHaveBeenCalledWith('documents'); }); - it('leaves a binding whose provider is off out of the due list', () => { - makeLink(); - const on = makeLink({ providerId: 'nextcloud' }); + it('leaves a binding whose provider is off out of the due list', async () => { + await makeLink(); + const on = await makeLink({ providerId: 'nextcloud' }); switchProvider('paperless', false); switchProvider('nextcloud', true); - expect(service.dueLinks().map((l) => l.id)).toEqual([on.id]); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([on.id]); }); - it('does not let switched-off bindings take the slots of the ones that may run', () => { + it('does not let switched-off bindings take the slots of the ones that may run', async () => { // They never run, so they never get a last_sync_at and would sort first // on every tick for good. - for (let i = 0; i < 25; i += 1) makeLink({ lastSyncAt: null }); - const on = makeLink({ providerId: 'nextcloud', lastSyncAt: sqlTime('-1 minute') }); + for (let i = 0; i < 25; i += 1) await makeLink({ lastSyncAt: null }); + const on = await makeLink({ providerId: 'nextcloud', lastSyncAt: sqlTime('-1 minute') }); switchProvider('paperless', false); switchProvider('nextcloud', true); - expect(service.dueLinks().map((l) => l.id)).toEqual([on.id]); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([on.id]); }); it('stands down without touching the binding or its documents while the provider is off', async () => { - const link = makeLink({ failureCount: 2 }); + const link = await makeLink({ failureCount: 2 }); testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'partial', last_sync_error = 'timeout' WHERE id = ?") .run(link.id); const itemId = seedItem(link, { remoteId: 'r1', remoteVersion: 'v1', state: 'error' }); @@ -1033,7 +1033,7 @@ describe('DocSyncService', () => { const itemBefore = itemRow(itemId); switchProvider('paperless', false); - const res = await service.syncLink(config.getLink(link.id)); + const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('disabled'); expect(provider.resolveScope).not.toHaveBeenCalled(); @@ -1043,7 +1043,7 @@ describe('DocSyncService', () => { }); it('stands down the same way while the Documents addon is off', async () => { - const link = makeLink(); + const link = await makeLink(); const before = linkRow(link.id); addons.isAddonEnabled.mockReturnValue(false); @@ -1055,7 +1055,7 @@ describe('DocSyncService', () => { }); it('stands down before it looks for the connection, so a gone one is not a failure either', async () => { - const link = { ...makeLink(), connection_id: 999999 }; + const link = { ...(await makeLink()), connection_id: 999999 }; switchProvider('paperless', false); expect((await service.syncLink(link)).state).toBe('disabled'); @@ -1063,15 +1063,15 @@ describe('DocSyncService', () => { }); it('picks up where it stopped once the provider is back on', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValue(ok(listing([remoteDoc()]))); switchProvider('paperless', false); await service.syncLink(link); expect(fileRows()).toHaveLength(0); switchProvider('paperless', true); - expect(service.dueLinks().map((l) => l.id)).toEqual([link.id]); - const res = await service.syncLink(config.getLink(link.id)); + expect((await service.dueLinks()).map((l) => l.id)).toEqual([link.id]); + const res = await service.syncLink(await config.getLink(link.id)); expect(res).toMatchObject({ state: 'ok', pulled: 1 }); expect(fileRows()).toHaveLength(1); @@ -1080,11 +1080,11 @@ describe('DocSyncService', () => { it('never runs an orphaned binding, whoever asks', async () => { // Sync automatically switched back on puts sync_enabled to 1 again, and a // webhook or a resolved conflict calls syncLink without asking first. - const link = makeLink(); + const link = await makeLink(); testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(link.id); const before = linkRow(link.id); - const res = await service.syncLink(config.getLink(link.id)); + const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('orphaned'); expect(provider.resolveScope).not.toHaveBeenCalled(); @@ -1093,7 +1093,7 @@ describe('DocSyncService', () => { }); it('answers the same question for any caller that has to refuse up front', async () => { - const link = makeLink(); + const link = await makeLink(); expect(await service.isSwitchedOff(link)).toBe(false); switchProvider('paperless', false); @@ -1107,7 +1107,7 @@ describe('DocSyncService', () => { describe('status', () => { it('reports the trip bindings together with a count per item state', async () => { - const link = makeLink(); + const link = await makeLink(); seedItem(link, { remoteId: 'r1', state: 'synced' }); seedItem(link, { remoteId: 'r2', state: 'synced' }); seedItem(link, { remoteId: 'r3', state: 'conflict' }); @@ -1119,8 +1119,8 @@ describe('DocSyncService', () => { }); it('says which bindings are paused because their provider is switched off', async () => { - const off = makeLink(); - const on = makeLink({ providerId: 'nextcloud' }); + const off = await makeLink(); + const on = await makeLink({ providerId: 'nextcloud' }); switchProvider('paperless', false); switchProvider('nextcloud', true); @@ -1147,7 +1147,7 @@ describe('DocSyncService', () => { } it('keeps both copies and gives each its own pairing when the user asks for both', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId, fileId } = seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), @@ -1179,7 +1179,7 @@ describe('DocSyncService', () => { // forgetting the agreed bytes: clearing the latter reads as "TREK never // agreed to this", and the provider's copy comes down over the one the // user just chose to keep. - const link = makeLink(); + const link = await makeLink(); const { itemId } = seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), @@ -1192,7 +1192,7 @@ describe('DocSyncService', () => { }); it('pulls the provider copy when the conflict was resolved in its favour', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId } = seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), @@ -1225,7 +1225,7 @@ describe('DocSyncService', () => { } it('renames the provider copy to TREK name when TREK wins', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId } = seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Invoice.pdf', remoteVersion: 'v2' }), @@ -1240,7 +1240,7 @@ describe('DocSyncService', () => { }); it('takes the local rename back when the provider wins, so TREK follows next run', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId, fileId } = seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Rechnung.pdf', remoteVersion: 'v1' }), @@ -1256,7 +1256,7 @@ describe('DocSyncService', () => { // Written raw, the colon made the next run read TREK's name as a second // rename of its own, and the conflict the owner had just settled came // straight back. - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'Invoice.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'Hotel: Kyoto.pdf', remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', @@ -1275,7 +1275,7 @@ describe('DocSyncService', () => { it('does not leave the row in conflict either way', async () => { for (const keep of ['trek', 'provider'] as const) { - const link = makeLink(); + const link = await makeLink(); const { itemId } = seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Rechnung.pdf', remoteVersion: 'v1' }), @@ -1289,7 +1289,7 @@ describe('DocSyncService', () => { }); it('refuses to resolve a pairing that is not in conflict', async () => { - const link = makeLink(); + const link = await makeLink(); const itemId = seedItem(link, { remoteId: 'r1', state: 'synced' }); expect(await service.resolveConflict(itemId, 'both')).toBe(false); @@ -1298,15 +1298,15 @@ describe('DocSyncService', () => { }); describe('issues', () => { - it('lists only the rows a person has to decide about, never the ones still waiting', () => { - const link = makeLink(); + it('lists only the rows a person has to decide about, never the ones still waiting', async () => { + const link = await makeLink(); const decidable = ['conflict', 'rejected_type', 'too_large', 'remote_missing', 'error']; const waiting = ['pending', 'synced', 'local_deleted']; for (const state of [...decidable, ...waiting]) { seedItem(link, { remoteId: `r-${state}`, state }); } - const states = service.issues(tripId).map((r) => r.state as string).sort(); + const states = (await service.issues(tripId)).map((r) => r.state as string).sort(); expect(states).toEqual([...decidable].sort()); }); }); @@ -1346,7 +1346,7 @@ describe('DocSyncService', () => { } it('puts the superseded copy in the trash instead of leaving it beside the new one', async () => { - const link = makeLink(); + const link = await makeLink(); const { fileId } = seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), @@ -1363,7 +1363,7 @@ describe('DocSyncService', () => { }); it('leaves exactly one live document, so the file manager does not show two', async () => { - const link = makeLink(); + const link = await makeLink(); seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), @@ -1375,7 +1375,7 @@ describe('DocSyncService', () => { }); it('does not push the superseded copy back on the next run', async () => { - const link = makeLink(); + const link = await makeLink(); seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), @@ -1392,7 +1392,7 @@ describe('DocSyncService', () => { }); it('leaves a first download alone: there is nothing to supersede', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-new', name: 'fresh.pdf', remoteVersion: 'v1' }), ]))); @@ -1411,7 +1411,7 @@ describe('DocSyncService', () => { * reservation showed no document until somebody dug the old copy out. */ it('carries the attachments of the superseded copy over to the new revision', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId, fileId } = seedSynced(link); const reservationId = Number( testDb.prepare("INSERT INTO reservations (trip_id, title) VALUES (?, 'Hotel Kyoto')").run(tripId).lastInsertRowid, @@ -1449,7 +1449,7 @@ describe('DocSyncService', () => { * compared before anything is written. */ it('keeps the file when the download turns out to be the bytes TREK already holds', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'invoice.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, @@ -1472,7 +1472,7 @@ describe('DocSyncService', () => { it('leaves a rename that came with such an edit for the planner to follow', async () => { // Moving the agreed name here would read the store's rename as TREK's // own on the next run and send the old name back up. - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'invoice.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, @@ -1505,7 +1505,7 @@ describe('DocSyncService', () => { */ describe('video from the store', () => { it('is pulled, not refused for its type', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r-clip', name: 'flug.mp4', mimeType: 'video/mp4' })]))); const res = await service.syncLink(link); @@ -1543,7 +1543,7 @@ describe('DocSyncService', () => { }); it('keeps the version TREK holds on record, not the one it failed to get', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId, fileId } = seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); @@ -1557,14 +1557,14 @@ describe('DocSyncService', () => { }); it('fetches the update again once the backoff is over and ends synced with the new bytes', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId, fileId } = seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); settleBackoff(); - const res = await service.syncLink(config.getLink(link.id)); + const res = await service.syncLink(await config.getLink(link.id)); expect(res).toMatchObject({ state: 'ok', pulled: 1 }); expect(provider.fetch).toHaveBeenCalledTimes(2); @@ -1577,32 +1577,32 @@ describe('DocSyncService', () => { expect(live).toHaveLength(1); expect(Number(live[0].id)).toBe(Number(row.file_id)); expect(Number(row.file_id)).not.toBe(fileId); - expect(service.issues(tripId)).toHaveLength(0); + expect(await service.issues(tripId)).toHaveLength(0); }); it('is fetched again by "Sync now" without waiting out the backoff', async () => { - const link = makeLink(); + const link = await makeLink(); const { itemId } = seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('provider_error')); await service.syncLink(link); - service.retryShelvedItems(link.id); - await service.syncLink(config.getLink(link.id)); + await service.retryShelvedItems(link.id); + await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(2); expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_version: 'v2', content_sha256: FILE_SHA }); }); it('still retries a first download, which has no version to keep', async () => { - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r-new', name: 'fresh.pdf', remoteVersion: 'v1' })]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); expect(itemRows()[0]).toMatchObject({ state: 'error', file_id: null, remote_id: 'r-new', remote_version: null }); settleBackoff(); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(2); expect(itemRows()).toHaveLength(1); @@ -1652,7 +1652,7 @@ describe('DocSyncService', () => { .get() as Record; it('counts consecutive failures', async () => { - const link = makeLink(); + const link = await makeLink(); await failingRun(link); expect(flakyRow().attempts).toBe(1); await failingRun(link); @@ -1660,7 +1660,7 @@ describe('DocSyncService', () => { }); it('clears the counter once a pass succeeds, so the limit means six in a row', async () => { - const link = makeLink(); + const link = await makeLink(); await failingRun(link); await failingRun(link); expect(flakyRow().attempts).toBe(2); @@ -1671,7 +1671,7 @@ describe('DocSyncService', () => { }); it('schedules each failure further out than the last', async () => { - const link = makeLink(); + const link = await makeLink(); const waits: number[] = []; for (let i = 0; i < 3; i += 1) { await failingRun(link); @@ -1706,7 +1706,7 @@ describe('DocSyncService', () => { } it('does not reach back when the binding is switched from unlink to trash later', async () => { - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId } = deletedPairing(link); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); @@ -1715,33 +1715,33 @@ describe('DocSyncService', () => { expect(itemRow(itemId).remote_trashed_at).toBeNull(); testDb.prepare("UPDATE trip_document_links SET delete_policy = 'trash' WHERE id = ?").run(link.id); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.trash).not.toHaveBeenCalled(); expect(itemRow(itemId).state).toBe('local_deleted'); }); it('bins the provider copy exactly once, even while the provider keeps listing it', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const { itemId } = deletedPairing(link); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); expect(itemRow(itemId).remote_trashed_at).not.toBeNull(); - await service.syncLink(config.getLink(link.id)); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.trash).toHaveBeenCalledTimes(1); }); it('does not ask again under an unchanged upstream either', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); deletedPairing(link); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); provider.list.mockResolvedValue(ok(listing([], { cursorUnchanged: true }))); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.trash).toHaveBeenCalledTimes(1); }); @@ -1782,12 +1782,12 @@ describe('DocSyncService', () => { } it('resumes the pairing without a transfer when the provider copy is still there', async () => { - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run.state).toBe('ok'); expect(provider.fetch).not.toHaveBeenCalled(); @@ -1797,7 +1797,7 @@ describe('DocSyncService', () => { }); it('lets the ordinary rules bring down an edit made upstream in the meantime', async () => { - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); restore(fileId); @@ -1806,7 +1806,7 @@ describe('DocSyncService', () => { const edited = Buffer.from('trek-document-bytes, revised upstream'); provider.fetch.mockResolvedValueOnce(ok({ body: Readable.from([edited]), size: edited.length, mimeType: 'application/pdf', remoteVersion: 'v2' })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', remoteVersion: 'v2' })]))); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run.pulled).toBe(1); const row = itemRow(itemId); @@ -1816,14 +1816,14 @@ describe('DocSyncService', () => { }); it('puts the file back at the provider when TREK was the one that binned it', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); expect(provider.trash).toHaveBeenCalledTimes(1); const uid = itemRow(itemId).trek_doc_uid; restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run.pushed).toBe(1); expect(provider.push.mock.calls[0][2]).toMatchObject({ fileName: 'boarding.pdf', remoteId: undefined, trekDocUid: uid }); @@ -1838,16 +1838,16 @@ describe('DocSyncService', () => { it('does not mistake the re-upload for a foreign change on the run after', async () => { // The echo guard must still recognise TREK's own write once the provider // reports it back under a new version marker. - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-pushed', name: 'boarding.pdf', remoteVersion: 'v2', contentHash: FILE_SHA }), ]))); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run.pulled).toBe(0); expect(provider.fetch).not.toHaveBeenCalled(); @@ -1856,19 +1856,19 @@ describe('DocSyncService', () => { }); it('retries a re-upload that failed like any other unpaired file', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); restore(fileId); provider.list.mockResolvedValue(ok(listing([]))); provider.push.mockResolvedValueOnce(fail('unreachable')); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); const failed = itemRow(itemId); expect(failed.state).toBe('error'); expect(failed.remote_id).toBeNull(); testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL WHERE id = ?').run(itemId); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.push).toHaveBeenCalledTimes(2); expect(itemRow(itemId).state).toBe('synced'); @@ -1876,7 +1876,7 @@ describe('DocSyncService', () => { }); it('uploads many files TREK had binned instead of reading them as a mass deletion', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const fileIds = Array.from({ length: 10 }, (_, i) => makeFile({ name: `doc-${i}.pdf`, storageKey: `key-${i}`, deletedAt: '2026-09-18 08:00:00' })); fileIds.forEach((fileId, i) => @@ -1892,7 +1892,7 @@ describe('DocSyncService', () => { return ok({ remoteId: `r-new-${seq}`, remoteVersion: 'v1', remoteModifiedAt: null, deduplicated: false }); }); provider.list.mockResolvedValueOnce(ok(listing([]))); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run.errorCode).toBeUndefined(); expect(run.state).toBe('ok'); @@ -1904,17 +1904,17 @@ describe('DocSyncService', () => { // Seen while TREK's file was still in the trash, so the note that TREK // binned it is dropped. Without that, a later restore on a quiet binding // would upload a second copy. - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(itemRow(itemId).remote_trashed_at).toBeNull(); expect(provider.fetch).not.toHaveBeenCalled(); restore(fileId); provider.list.mockImplementationOnce(quietUnlessAsked([remoteDoc({ remoteId: 'r1' })])); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); expect(itemRow(itemId).state).toBe('synced'); @@ -1925,13 +1925,13 @@ describe('DocSyncService', () => { // An unchanged cursor shows nothing of the copy, and on a quiet binding // it stays unchanged until somebody touches the folder. Resumed blind, // the row was booked as synced and counted as vanished later on. - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); const visa = remoteDoc({ remoteId: 'r-visa', name: 'visa.pdf' }); restore(fileId); provider.list.mockImplementation(quietUnlessAsked([visa])); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[1][1].cursor).toBeNull(); expect(run).toMatchObject({ state: 'ok', pulled: 1, pushed: 0, missing: 1 }); @@ -1939,7 +1939,7 @@ describe('DocSyncService', () => { expect(itemRow(itemId).remote_missing_at).not.toBeNull(); // Once the restore is settled the binding goes back to its cursor. - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[2][1].cursor).toBe('cursor-1'); }); @@ -1948,7 +1948,7 @@ describe('DocSyncService', () => { // mass-delete guard. The run was dropped before the restores were // written, the next run planned the same, and the binding synced nothing // at all, new documents included, until its circuit opened. - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const inStep = Array.from({ length: 6 }, (_, i) => { const fileId = makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); seedItem(link, { remoteId: `s${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, contentSha256: FILE_SHA }); @@ -1966,7 +1966,7 @@ describe('DocSyncService', () => { provider.list.mockResolvedValueOnce(ok(listing([ ...inStep, newThere, remoteDoc({ remoteId: 'r-pushed', name: 'new-here.pdf' }), ]))); - const second = await service.syncLink(config.getLink(link.id)); + const second = await service.syncLink(await config.getLink(link.id)); expect(first).toMatchObject({ state: 'ok', pulled: 1, pushed: 1, missing: 5 }); expect(first.errorCode).toBeUndefined(); @@ -1976,17 +1976,17 @@ describe('DocSyncService', () => { }); it('flags the copy as missing when it vanished upstream by somebody else', async () => { - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); expect(run.missing).toBe(1); expect(itemRow(itemId).state).toBe('remote_missing'); - expect(service.issues(tripId).map((r) => r.id)).toContain(itemId); + expect((await service.issues(tripId)).map((r) => r.id)).toContain(itemId); }); }); @@ -2000,14 +2000,14 @@ describe('DocSyncService', () => { const purge = (fileId: number) => testDb.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId); it('does not download a document again once its deletion was seen', async () => { - const link = makeLink({ deletePolicy: 'unlink' }); + const link = await makeLink({ deletePolicy: 'unlink' }); const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); purge(fileId); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).not.toHaveBeenCalled(); expect(fileRows()).toHaveLength(0); @@ -2015,14 +2015,14 @@ describe('DocSyncService', () => { }); it('applies the delete policy when the purge came before any run saw the deletion', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); purge(fileId); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.trash).toHaveBeenCalledTimes(1); @@ -2034,7 +2034,7 @@ describe('DocSyncService', () => { // A failed update keeps the file and leaves the row in `error`, where the // backoff drops the deletion. Purged in that window, the row read as a // first download that never landed, and "Sync now" fetched it again. - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const fileId = makeFile({ name: 'boarding.pdf' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, @@ -2045,8 +2045,8 @@ describe('DocSyncService', () => { expect(itemRow(itemId)).toMatchObject({ state: 'error', file_id: fileId }); purge(fileId); - service.retryShelvedItems(link.id); - await service.syncLink(config.getLink(link.id)); + await service.retryShelvedItems(link.id); + await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(1); expect(fileRows()).toHaveLength(0); @@ -2064,7 +2064,7 @@ describe('DocSyncService', () => { * that is not there, whatever the delete policy says. */ it('closes a row on record as gone from the store once the TREK copy is purged too', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', @@ -2078,7 +2078,7 @@ describe('DocSyncService', () => { expect(provider.trash).not.toHaveBeenCalled(); expect(itemRow(itemId)).toMatchObject({ state: 'local_deleted', remote_trashed_at: null, file_id: null }); expect(itemRow(itemId).remote_missing_at).not.toBeNull(); - expect(service.issues(tripId).map((r) => r.id)).not.toContain(itemId); + expect((await service.issues(tripId)).map((r) => r.id)).not.toContain(itemId); // Closed is not "back at the store": the holdings must not count it there. const [status] = ((await service.status(tripId)) as { links: Array<{ holdings: { atProvider: number; missing: number } }> }).links; expect(status.holdings).toMatchObject({ atProvider: 0, missing: 0 }); @@ -2102,7 +2102,7 @@ describe('DocSyncService', () => { }); it('takes a rename made at the provider into TREK and does not rename it back', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'a.pdf' }); const itemId = seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'synced', @@ -2120,7 +2120,7 @@ describe('DocSyncService', () => { remote_id: '/trek/b.pdf', remote_name: 'b.pdf', remote_version: 'vb', state: 'synced', remote_missing_at: null, }); - const second = await service.syncLink(config.getLink(link.id)); + const second = await service.syncLink(await config.getLink(link.id)); expect(second).toMatchObject({ state: 'ok', missing: 0 }); expect(provider.rename).not.toHaveBeenCalled(); @@ -2131,7 +2131,7 @@ describe('DocSyncService', () => { }); it('takes a copy on record as missing off the list in the run that finds it renamed', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'a.pdf' }); const itemId = seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'remote_missing', @@ -2145,9 +2145,9 @@ describe('DocSyncService', () => { expect(itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', state: 'synced', remote_missing_at: null }); expect(fileRows().map((f) => f.original_name)).toEqual(['b.pdf']); - expect(service.issues(tripId)).toEqual([]); + expect(await service.issues(tripId)).toEqual([]); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(itemRow(itemId).state).toBe('synced'); expect(provider.rename).not.toHaveBeenCalled(); @@ -2156,7 +2156,7 @@ describe('DocSyncService', () => { it('recognises the copy by size and time where the listing has no hash', async () => { // What Synology actually sends. The pull records size and time, which is // all there is to go on once the path has changed. - const link = makeLink(); + const link = await makeLink(); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: '/trek/a.pdf', name: 'a.pdf', remoteVersion: 'va' }), ]))); @@ -2167,8 +2167,8 @@ describe('DocSyncService', () => { provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/2026/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }), ]))); - const renamed = await service.syncLink(config.getLink(link.id)); - await service.syncLink(config.getLink(link.id)); + const renamed = await service.syncLink(await config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(renamed.missing).toBe(0); expect(provider.fetch).toHaveBeenCalledTimes(1); @@ -2178,7 +2178,7 @@ describe('DocSyncService', () => { }); it('recognises a copy it uploaded itself when it is renamed before the next run', async () => { - const link = makeLink(); + const link = await makeLink(); makeFile({ name: 'boarding.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([]))); provider.push.mockResolvedValueOnce(ok({ @@ -2189,8 +2189,8 @@ describe('DocSyncService', () => { provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/flight.pdf', name: 'flight.pdf', remoteVersion: 'v2', size: FILE_BYTES.length }), ]))); - await service.syncLink(config.getLink(link.id)); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.push).toHaveBeenCalledTimes(1); expect(provider.fetch).not.toHaveBeenCalled(); @@ -2202,7 +2202,7 @@ describe('DocSyncService', () => { it('finds the copy again after sending a rename made in TREK upstream', async () => { // The adapter cannot report the path its rename produced, so the row // keeps the old one until the next listing shows where the copy went. - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'b.pdf' }); const itemId = seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'synced', @@ -2218,8 +2218,8 @@ describe('DocSyncService', () => { provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }), ]))); - const next = await service.syncLink(config.getLink(link.id)); - await service.syncLink(config.getLink(link.id)); + const next = await service.syncLink(await config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(next.missing).toBe(0); expect(provider.rename).toHaveBeenCalledTimes(1); @@ -2269,36 +2269,36 @@ describe('DocSyncService', () => { } it('flags a copy deleted at the provider right after TREK uploaded it', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'boarding.pdf' }); const held = digestScope(); expect(await service.syncLink(link)).toMatchObject({ state: 'ok', pushed: 1 }); const itemId = Number(itemRows()[0].id); held.clear(); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run).toMatchObject({ state: 'ok', missing: 1 }); expect(itemRow(itemId)).toMatchObject({ state: 'remote_missing', file_id: fileId }); expect(itemRow(itemId).remote_missing_at).not.toBeNull(); - expect(service.issues(tripId).map((r) => r.id)).toEqual([itemId]); + expect((await service.issues(tripId)).map((r) => r.id)).toEqual([itemId]); }); it('lets the mass-delete guard see a scope emptied right after an upload', async () => { - const link = makeLink(); + const link = await makeLink(); for (let i = 0; i < 6; i += 1) makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); const held = digestScope(); expect(await service.syncLink(link)).toMatchObject({ state: 'ok', pushed: 6 }); held.clear(); - const run = await service.syncLink(config.getLink(link.id)); + const run = await service.syncLink(await config.getLink(link.id)); expect(run).toMatchObject({ state: 'partial', errorCode: 'mass_delete_guard', missing: 6 }); expect(itemRows().every((r) => r.state === 'synced')).toBe(true); }); it('notices a copy TREK binned coming back, so a restore in TREK does not upload it twice', async () => { - const link = makeLink({ deletePolicy: 'trash' }); + const link = await makeLink({ deletePolicy: 'trash' }); const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, @@ -2312,18 +2312,18 @@ describe('DocSyncService', () => { // Out of the provider's recycle bin as it went in, which is the scope the // binning run listed. held.set('r1', copy); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(itemRow(itemId).remote_trashed_at).toBeNull(); testDb.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(fileId); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_id: 'r1' }); }); it('lists in full after a rename it sent, then goes back to its cursor', async () => { - const link = makeLink(); + const link = await makeLink(); const fileId = makeFile({ name: 'new.pdf' }); seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); digestScope([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]); @@ -2332,9 +2332,9 @@ describe('DocSyncService', () => { expect(provider.rename).toHaveBeenCalledTimes(1); expect(linkRow(link.id).remote_cursor).toBeNull(); - await service.syncLink(config.getLink(link.id)); + await service.syncLink(await config.getLink(link.id)); expect(linkRow(link.id).remote_cursor).toBe('digest:r1@v2'); - const quiet = await service.syncLink(config.getLink(link.id)); + const quiet = await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[2][1].cursor).toBe('digest:r1@v2'); expect(quiet).toMatchObject({ state: 'ok', pulled: 0, pushed: 0 }); @@ -2342,25 +2342,25 @@ describe('DocSyncService', () => { }); it('pulls what the transfer budget left over on the next ordinary run', async () => { - const link = makeLink(); + const link = await makeLink(); digestScope(Array.from({ length: MAX_TRANSFERS_PER_RUN + 5 }, (_, i) => remoteDoc({ remoteId: `r${i}`, name: `doc-${i}.pdf` }))); expect(await service.syncLink(link)).toMatchObject({ state: 'partial', pulled: MAX_TRANSFERS_PER_RUN }); - const rest = await service.syncLink(config.getLink(link.id)); + const rest = await service.syncLink(await config.getLink(link.id)); expect(rest).toMatchObject({ state: 'ok', pulled: 5 }); expect(fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN + 5); }); it('fetches a failed download again while nothing moves upstream', async () => { - const link = makeLink(); + const link = await makeLink(); digestScope([remoteDoc({ remoteId: 'r-new', name: 'fresh.pdf' })]); provider.fetch.mockResolvedValueOnce(fail('timeout')); expect(await service.syncLink(link)).toMatchObject({ state: 'partial', errorCode: 'timeout' }); testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL').run(); - const retry = await service.syncLink(config.getLink(link.id)); + const retry = await service.syncLink(await config.getLink(link.id)); expect(retry).toMatchObject({ state: 'ok', pulled: 1 }); expect(provider.fetch).toHaveBeenCalledTimes(2); @@ -2376,21 +2376,21 @@ describe('DocSyncService', () => { ).run(linkId, tripOfItem).lastInsertRowid); } it('refuses when the row belongs to another trip', async () => { - const link = makeLink(); + const link = await makeLink(); const itemId = conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek', link.trip_id + 999)).toBe(false); const after = testDb.prepare('SELECT state FROM document_sync_items WHERE id = ?').get(itemId) as { state: string }; expect(after.state).toBe('conflict'); }); it('resolves when the row belongs to the trip', async () => { - const link = makeLink(); + const link = await makeLink(); const itemId = conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek', link.trip_id)).toBe(true); const after = testDb.prepare('SELECT state FROM document_sync_items WHERE id = ?').get(itemId) as { state: string }; expect(after.state).not.toBe('conflict'); }); it('still works for a caller that names no trip, so nothing else breaks', async () => { - const link = makeLink(); + const link = await makeLink(); const itemId = conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek')).toBe(true); }); diff --git a/server/tests/unit/nest/doc-sync/synology-drive.provider.test.ts b/server/tests/unit/nest/doc-sync/synology-drive.provider.test.ts index ea4fed0d6..12b86ffad 100644 --- a/server/tests/unit/nest/doc-sync/synology-drive.provider.test.ts +++ b/server/tests/unit/nest/doc-sync/synology-drive.provider.test.ts @@ -470,7 +470,7 @@ describe('SynologyDriveDocumentProvider: device tokens', () => { function saved(store: Record, overrides: Partial = {}): DocumentConnectionRef { return connection({ secrets: { ...store }, - saveSecret: (key, value) => { + saveSecret: async (key, value) => { writes.push([key, value]); if (value === null) delete store[key]; else store[key] = value; diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 56e6b60e9..83acee59e 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -143,7 +143,7 @@ beforeAll(async () => { daysSvc, new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, - new VacayService(dbs(), new RealtimeService(), notificationsStub()), + new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), undefined as never, // unsplash — not exercised here coversFx.storage, @@ -1210,7 +1210,7 @@ describe('quirk fixes', () => { daysSvc, new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, - new VacayService(dbs(), new RealtimeService(), notificationsStub()), + new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), undefined as never, coversFx.storage, diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts index dcd58399c..852b2af42 100644 --- a/server/tests/unit/nest/vacay.controller.test.ts +++ b/server/tests/unit/nest/vacay.controller.test.ts @@ -25,9 +25,9 @@ async function thrown(fn: () => unknown): Promise<{ status: number; body: unknow const planBase = { getActivePlanId: vi.fn().mockReturnValue(10), getActivePlan: vi.fn().mockReturnValue({ id: 10 }) }; describe('VacayController (parity with the legacy /api/addons/vacay route)', () => { - it('GET /plan delegates getPlanData', () => { + it('GET /plan delegates getPlanData', async () => { const getPlanData = vi.fn().mockReturnValue({ plan: { id: 10 } }); - expect(makeController({ getPlanData }).getPlan(user)).toEqual({ plan: { id: 10 } }); + expect(await makeController({ getPlanData }).getPlan(user)).toEqual({ plan: { id: 10 } }); }); it('PUT /plan forwards the socket id', async () => { @@ -39,9 +39,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () describe('holiday calendars', () => { // A missing region now 400s in the global ZodValidationPipe (the schema // requires it), so the bespoke 'region required' guard is gone. - it('creates a calendar', () => { + it('creates a calendar', async () => { const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' }); - const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock'); + const res = await makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock'); expect(res).toEqual({ calendar: { id: 1, region: 'DE-BY' } }); expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock', undefined); }); @@ -66,10 +66,10 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } })); }); - it('sets the colour for an in-plan user', () => { + it('sets the colour for an in-plan user', async () => { const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]); const setUserColor = vi.fn(); - expect(makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock')).toEqual({ success: true }); + expect(await makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock')).toEqual({ success: true }); expect(setUserColor).toHaveBeenCalledWith(1, 10, '#fff', 'sock'); }); }); @@ -88,9 +88,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 409, body: { error: 'Already in a plan' } })); }); - it('sends an invite', () => { + it('sends an invite', async () => { const sendInvite = vi.fn().mockReturnValue({}); - expect(makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true }); + expect(await makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true }); expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2); }); @@ -100,11 +100,11 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 404, body: { error: 'Invite not found' } })); }); - it('decline / cancel / dissolve return success', () => { + it('decline / cancel / dissolve return success', async () => { const declineInvite = vi.fn(); const cancelInvite = vi.fn(); const dissolvePlan = vi.fn(); - expect(makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true }); - expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({ success: true }); - expect(makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true }); + expect(await makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true }); + expect(await makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({ success: true }); + expect(await makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true }); }); }); @@ -116,10 +116,10 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 400, body: { error: 'Year required' } })); }); - it('adds and deletes years', () => { + it('adds and deletes years', async () => { const addYear = vi.fn().mockReturnValue([2026]); const deleteYear = vi.fn().mockReturnValue([]); - expect(makeController({ ...planBase, addYear }).addYear(user, { year: 2026 }, 'sock')).toEqual({ years: [2026] }); - expect(makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] }); + expect(await makeController({ ...planBase, addYear }).addYear(user, { year: 2026 }, 'sock')).toEqual({ years: [2026] }); + expect(await makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] }); }); }); @@ -132,15 +132,15 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } })); }); - it('toggles for the caller', () => { + it('toggles for the caller', async () => { const toggleEntry = vi.fn().mockReturnValue({ action: 'added' }); - expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' }); + expect(await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' }); expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock'); }); - it('forwards the half-day fraction (#552)', () => { + it('forwards the half-day fraction (#552)', async () => { const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 }); - makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock'); + await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock'); expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, undefined, 'sock'); }); @@ -150,30 +150,30 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 400, body: { error: 'Weekend days are blocked on this plan' } })); }); - it('forwards the comp/flex leave type (#1074)', () => { + it('forwards the comp/flex leave type (#1074)', async () => { const toggleEntry = vi.fn().mockReturnValue({ action: 'added', kind: 'comp' }); - makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', kind: 'comp' }, 'sock'); + await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', kind: 'comp' }, 'sock'); expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'comp', 'sock'); }); - it('GET reads entries over the caller window (#737)', () => { + it('GET reads entries over the caller window (#737)', async () => { const getEntries = vi.fn().mockReturnValue({ entries: [], companyHolidays: [] }); - makeController({ ...planBase, getEntries }).entries(user, '2026'); + await makeController({ ...planBase, getEntries }).entries(user, '2026'); expect(getEntries).toHaveBeenCalledWith(10, '2026', 1); }); }); describe('year settings (#737)', () => { - it('GET wraps the caller settings', () => { + it('GET wraps the caller settings', async () => { const getYearSettings = vi.fn().mockReturnValue({ year_type: 'calendar' }); - expect(makeController({ getYearSettings }).yearSettings(user)).toEqual({ settings: { year_type: 'calendar' } }); + expect(await makeController({ getYearSettings }).yearSettings(user)).toEqual({ settings: { year_type: 'calendar' } }); expect(getYearSettings).toHaveBeenCalledWith(1); }); - it('PUT saves for the caller and returns the stored settings', () => { + it('PUT saves for the caller and returns the stored settings', async () => { const updateYearSettings = vi.fn().mockReturnValue({ year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); const body = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1 }; - expect(makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({ + expect(await makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({ settings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }, }); expect(updateYearSettings).toHaveBeenCalledWith(1, body); @@ -181,9 +181,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () }); describe('stats', () => { - it('GET wraps stats', () => { + it('GET wraps stats', async () => { const getStats = vi.fn().mockReturnValue({ used: 5 }); - expect(makeController({ ...planBase, getStats }).stats(user, '2026')).toEqual({ stats: { used: 5 } }); + expect(await makeController({ ...planBase, getStats }).stats(user, '2026')).toEqual({ stats: { used: 5 } }); }); it('403 on updateStats for a user not in the plan', () => { @@ -213,9 +213,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () }); describe('read-only shares', () => { - it('GET /shares delegates listShares', () => { + it('GET /shares delegates listShares', async () => { const listShares = vi.fn().mockReturnValue({ outgoing: [{ id: 1 }], incoming: [] }); - expect(makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] }); + expect(await makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] }); expect(listShares).toHaveBeenCalledWith(1); }); @@ -230,27 +230,27 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 400, body: { error: 'Already shared' } })); }); - it('shares a calendar (user_id coerced to a number, socket id forwarded)', () => { + it('shares a calendar (user_id coerced to a number, socket id forwarded)', async () => { const shareCalendar = vi.fn().mockReturnValue({}); - expect(makeController({ shareCalendar }).share(user, { user_id: '2' }, 'sock-1')).toEqual({ success: true }); + expect(await makeController({ shareCalendar }).share(user, { user_id: '2' }, 'sock-1')).toEqual({ success: true }); expect(shareCalendar).toHaveBeenCalledWith(1, 'u@example.test', 2, 'sock-1'); }); - it('GET /shares/available-users wraps the user list', () => { + it('GET /shares/available-users wraps the user list', async () => { const getShareAvailableUsers = vi.fn().mockReturnValue([{ id: 3 }]); - expect(makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({ users: [{ id: 3 }] }); + expect(await makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({ users: [{ id: 3 }] }); expect(getShareAvailableUsers).toHaveBeenCalledWith(1); }); - it('GET /shares/calendars/:year wraps the calendars', () => { + it('GET /shares/calendars/:year wraps the calendars', async () => { const getSharedCalendars = vi.fn().mockReturnValue([{ share_id: 4 }]); - expect(makeController({ getSharedCalendars }).sharedCalendars(user, '2026')).toEqual({ calendars: [{ share_id: 4 }] }); + expect(await makeController({ getSharedCalendars }).sharedCalendars(user, '2026')).toEqual({ calendars: [{ share_id: 4 }] }); expect(getSharedCalendars).toHaveBeenCalledWith(1, '2026'); }); - it('PUT /shares/:id forwards the socket id and the hidden flag', () => { + it('PUT /shares/:id forwards the socket id and the hidden flag', async () => { const setShareHidden = vi.fn().mockReturnValue(true); - expect(makeController({ setShareHidden }).updateShare(user, '7', { hidden: true }, 'sock-1')).toEqual({ success: true }); + expect(await makeController({ setShareHidden }).updateShare(user, '7', { hidden: true }, 'sock-1')).toEqual({ success: true }); expect(setShareHidden).toHaveBeenCalledWith(7, 1, true, 'sock-1'); }); @@ -260,9 +260,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () expect(r).toEqual({ status: 404, body: { error: 'Share not found' } })); }); - it('DELETE /shares/:id forwards the socket id', () => { + it('DELETE /shares/:id forwards the socket id', async () => { const removeShare = vi.fn().mockReturnValue(true); - expect(makeController({ removeShare }).deleteShare(user, '7', 'sock-1')).toEqual({ success: true }); + expect(await makeController({ removeShare }).deleteShare(user, '7', 'sock-1')).toEqual({ success: true }); expect(removeShare).toHaveBeenCalledWith(7, 1, 'sock-1'); }); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 7ca63831a..fd3e1fab5 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -36,6 +36,7 @@ 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'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; // VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy // tests/unit/services/vacayService.test.ts (the named-function imports became @@ -43,13 +44,14 @@ import { notificationsStub } from '../../helpers/notifications'; // 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()); +let svc: VacayService; // ── Lifecycle ───────────────────────────────────────────────────────────────── beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); }); beforeEach(() => { @@ -77,9 +79,9 @@ function insertMember(planId: number, userId: number, status: 'pending' | 'accep } /** Fast helper: create a user and immediately materialise their own plan. */ -function setupUserWithPlan() { +async function setupUserWithPlan() { const { user } = createUser(testDb); - const plan = svc.getOwnPlan(user.id); + const plan = await svc.getOwnPlan(user.id); return { user, plan }; } @@ -94,26 +96,26 @@ function allowWeekends(planId: number) { // ── getOwnPlan ──────────────────────────────────────────────────────────────── describe('getOwnPlan', () => { - it('VACAY-SVC-001: creates a new plan on first call for a fresh user', () => { + it('VACAY-SVC-001: creates a new plan on first call for a fresh user', async () => { const { user } = createUser(testDb); - const plan = svc.getOwnPlan(user.id); + const plan = await 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)', () => { + it('VACAY-SVC-002: returns the same plan on a second call (idempotent)', async () => { const { user } = createUser(testDb); - const first = svc.getOwnPlan(user.id); - const second = svc.getOwnPlan(user.id); + const first = await svc.getOwnPlan(user.id); + const second = await 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', () => { + it('VACAY-SVC-003: seeds the current year row in vacay_years after plan creation', async () => { const { user } = createUser(testDb); - const plan = svc.getOwnPlan(user.id); + const plan = await svc.getOwnPlan(user.id); const yr = new Date().getFullYear(); const row = testDb @@ -123,9 +125,9 @@ describe('getOwnPlan', () => { expect(row).toBeDefined(); }); - it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', () => { + it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', async () => { const { user } = createUser(testDb); - const plan = svc.getOwnPlan(user.id); + const plan = await svc.getOwnPlan(user.id); const yr = new Date().getFullYear(); const row = testDb @@ -140,34 +142,34 @@ describe('getOwnPlan', () => { // ── 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); + it('VACAY-SVC-005: returns own plan when user has no accepted membership in another plan', async () => { + const { user, plan } = await setupUserWithPlan(); + const active = await 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(); + it('VACAY-SVC-006: returns the shared plan when user has an accepted membership in another plan', async () => { + const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); // Make sure member also has their own plan materialised first - svc.getOwnPlan(member.id); + await svc.getOwnPlan(member.id); insertMember(ownerPlan.id, member.id, 'accepted'); - const active = svc.getActivePlan(member.id); + const active = await 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(); + it('VACAY-SVC-007: pending membership does NOT override own plan as active', async () => { + const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); - svc.getOwnPlan(member.id); + await svc.getOwnPlan(member.id); insertMember(ownerPlan.id, member.id, 'pending'); - const active = svc.getActivePlan(member.id); + const active = await svc.getActivePlan(member.id); // Should still point to member's own plan expect(active.owner_id).toBe(member.id); }); @@ -176,37 +178,37 @@ describe('getActivePlan', () => { // ── getPlanUsers ────────────────────────────────────────────────────────────── describe('getPlanUsers', () => { - it('VACAY-SVC-008: returns [owner] for a solo plan', () => { - const { user, plan } = setupUserWithPlan(); - const users = svc.getPlanUsers(plan.id); + it('VACAY-SVC-008: returns [owner] for a solo plan', async () => { + const { user, plan } = await setupUserWithPlan(); + const users = await 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(); + it('VACAY-SVC-009: returns [owner, member] after an accepted membership is inserted', async () => { + const { user: owner, plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); insertMember(plan.id, member.id, 'accepted'); - const users = svc.getPlanUsers(plan.id); + const users = await 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(); + it('VACAY-SVC-010: pending membership members are NOT included in plan users', async () => { + const { plan } = await setupUserWithPlan(); const { user: pendingUser } = createUser(testDb); insertMember(plan.id, pendingUser.id, 'pending'); - const users = svc.getPlanUsers(plan.id); + const users = await 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); + it('VACAY-SVC-011: returns empty array for a non-existent plan id', async () => { + const users = await svc.getPlanUsers(99999); expect(users).toEqual([]); }); }); @@ -215,7 +217,7 @@ describe('getPlanUsers', () => { describe('migrateHolidayCalendars', () => { it('VACAY-SVC-012: does nothing when holidays_enabled is falsy', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); const planRow = { ...plan, holidays_enabled: 0, holidays_region: 'DE' }; await svc.migrateHolidayCalendars(plan.id, planRow); @@ -227,7 +229,7 @@ describe('migrateHolidayCalendars', () => { }); it('VACAY-SVC-013: inserts a calendar row when holidays_enabled=1 and holidays_region is set', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'DE' }; await svc.migrateHolidayCalendars(plan.id, planRow); @@ -240,7 +242,7 @@ describe('migrateHolidayCalendars', () => { }); it('VACAY-SVC-014: does nothing if a calendar row already exists (no duplicate)', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'FR' }; await svc.migrateHolidayCalendars(plan.id, planRow); @@ -258,7 +260,7 @@ describe('migrateHolidayCalendars', () => { describe('updatePlan', () => { it('VACAY-SVC-015: updates block_weekends flag', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); await svc.updatePlan(plan.id, { block_weekends: true }, undefined); @@ -269,7 +271,7 @@ describe('updatePlan', () => { }); it('VACAY-SVC-016: updates holidays_enabled flag', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); await svc.updatePlan(plan.id, { holidays_enabled: true }, undefined); @@ -280,7 +282,7 @@ describe('updatePlan', () => { }); it('VACAY-SVC-017: returns the updated plan object with boolean-coerced flags', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); const result = await svc.updatePlan(plan.id, { block_weekends: false }, undefined); @@ -289,7 +291,7 @@ describe('updatePlan', () => { }); 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 { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); // Manually set a non-zero carried_over value @@ -309,23 +311,23 @@ describe('updatePlan', () => { // ── addHolidayCalendar ──────────────────────────────────────────────────────── describe('addHolidayCalendar', () => { - it('validates manual region references for creates and updates', () => { - const { plan } = setupUserWithPlan(); + it('validates manual region references for creates and updates', async () => { + const { plan } = await setupUserWithPlan(); testDb.prepare("INSERT INTO school_holiday_countries (code, name) VALUES ('US', 'USA')").run(); const inserted = testDb.prepare("INSERT INTO school_holiday_regions (country, name) VALUES ('US', 'Seattle')").run(); const code = `US-MANUAL-${inserted.lastInsertRowid}`; - const calendar = svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday'); + const calendar = await svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday'); expect(calendar.region).toBe(code); - expect(svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined)?.region).toBe(code); - expect(() => svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).toThrow('Unknown manual'); + expect((await svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined))?.region).toBe(code); + await expect(svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).rejects.toThrow('Unknown manual'); for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted.lastInsertRowid}`]) { - expect(() => svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).toThrow('Unknown manual'); + await expect(svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).rejects.toThrow('Unknown manual'); } }); - it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', async () => { + const { plan } = await setupUserWithPlan(); - const cal = svc.addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined); + const cal = await svc.addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined); expect(cal).toBeDefined(); expect(cal.id).toBeGreaterThan(0); @@ -334,10 +336,10 @@ describe('addHolidayCalendar', () => { expect(cal.color).toBe('#ff0000'); }); - it('VACAY-SVC-020: uses default color #fecaca when no color is provided', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-020: uses default color #fecaca when no color is provided', async () => { + const { plan } = await setupUserWithPlan(); - const cal = svc.addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined); + const cal = await svc.addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined); expect(cal.color).toBe('#fecaca'); }); @@ -346,21 +348,21 @@ describe('addHolidayCalendar', () => { // ── 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); + it('VACAY-SVC-021: changes label and color on an existing calendar', async () => { + const { plan } = await setupUserWithPlan(); + const cal = await svc.addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined); - const updated = svc.updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined); + const updated = await 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(); + it('VACAY-SVC-022: returns null when the calendar id does not exist in the plan', async () => { + const { plan } = await setupUserWithPlan(); - const result = svc.updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined); + const result = await svc.updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined); expect(result).toBeNull(); }); @@ -369,21 +371,21 @@ describe('updateHolidayCalendar', () => { // ── 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); + it('VACAY-SVC-023: removes the calendar row and returns true on success', async () => { + const { plan } = await setupUserWithPlan(); + const cal = await svc.addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined); - const result = svc.deleteHolidayCalendar(cal.id, plan.id, undefined); + const result = await 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(); + it('VACAY-SVC-024: returns false when the calendar does not exist', async () => { + const { plan } = await setupUserWithPlan(); - const result = svc.deleteHolidayCalendar(99999, plan.id, undefined); + const result = await svc.deleteHolidayCalendar(99999, plan.id, undefined); expect(result).toBe(false); }); @@ -392,10 +394,10 @@ describe('deleteHolidayCalendar', () => { // ── setUserColor ────────────────────────────────────────────────────────────── describe('setUserColor', () => { - it('VACAY-SVC-025: inserts a color for a user in a plan', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-025: inserts a color for a user in a plan', async () => { + const { user, plan } = await setupUserWithPlan(); - svc.setUserColor(user.id, plan.id, '#123456', undefined); + await svc.setUserColor(user.id, plan.id, '#123456', undefined); const row = testDb .prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?') @@ -403,11 +405,11 @@ describe('setUserColor', () => { 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); + it('VACAY-SVC-026: updates the color when called a second time (upsert)', async () => { + const { user, plan } = await setupUserWithPlan(); + await svc.setUserColor(user.id, plan.id, '#aaaaaa', undefined); - svc.setUserColor(user.id, plan.id, '#bbbbbb', undefined); + await svc.setUserColor(user.id, plan.id, '#bbbbbb', undefined); const row = testDb .prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?') @@ -419,24 +421,24 @@ describe('setUserColor', () => { // ── listYears / addYear / deleteYear ────────────────────────────────────────── describe('listYears', () => { - it('VACAY-SVC-027: returns the seeded current year for a freshly created plan', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-027: returns the seeded current year for a freshly created plan', async () => { + const { plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - const years = svc.listYears(plan.id); + const years = await 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(); + it('VACAY-SVC-028: inserts a new year and creates a user_year record', async () => { + const { user, plan } = await setupUserWithPlan(); const newYear = new Date().getFullYear() + 2; - svc.addYear(plan.id, newYear, undefined); + await svc.addYear(plan.id, newYear, undefined); - const years = svc.listYears(plan.id); + const years = await svc.listYears(plan.id); expect(years).toContain(newYear); const userYear = testDb @@ -446,8 +448,8 @@ describe('addYear', () => { 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(); + it('VACAY-SVC-029: carries over remaining days to the new year when carry_over_enabled is true', async () => { + const { user, plan } = await setupUserWithPlan(); const currentYear = new Date().getFullYear(); const nextYear = currentYear + 1; @@ -464,7 +466,7 @@ describe('addYear', () => { 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); + await 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 = ?') @@ -475,17 +477,17 @@ describe('addYear', () => { }); describe('deleteYear', () => { - it('VACAY-SVC-030: removes the year row and its associated entries', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-030: removes the year row and its associated entries', async () => { + const { user, plan } = await setupUserWithPlan(); const targetYear = new Date().getFullYear() + 3; - svc.addYear(plan.id, targetYear, undefined); + await 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); + await svc.deleteYear(plan.id, targetYear, undefined); const yearRow = testDb .prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?') @@ -502,11 +504,11 @@ describe('deleteYear', () => { // ── getEntries / toggleEntry ────────────────────────────────────────────────── describe('getEntries', () => { - it('VACAY-SVC-031: returns empty entries and companyHolidays for a new plan+year', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-031: returns empty entries and companyHolidays for a new plan+year', async () => { + const { plan } = await setupUserWithPlan(); const yr = new Date().getFullYear().toString(); - const result = svc.getEntries(plan.id, yr); + const result = await svc.getEntries(plan.id, yr); expect(result.entries).toEqual([]); expect(result.companyHolidays).toEqual([]); @@ -514,10 +516,10 @@ describe('getEntries', () => { }); describe('toggleEntry', () => { - it('VACAY-SVC-032: adds an entry on first call (action: added)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-032: adds an entry on first call (action: added)', async () => { + const { user, plan } = await setupUserWithPlan(); - const result = svc.toggleEntry(user.id, plan.id, '2025-08-01', undefined); + const result = await svc.toggleEntry(user.id, plan.id, '2025-08-01', undefined); expect(result.action).toBe('added'); const row = testDb @@ -526,11 +528,11 @@ describe('toggleEntry', () => { expect(row).toBeDefined(); }); - it('VACAY-SVC-033: removes the entry on second call (action: removed)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033: removes the entry on second call (action: removed)', async () => { + const { user, plan } = await setupUserWithPlan(); - svc.toggleEntry(user.id, plan.id, '2025-08-04', undefined); - const result = svc.toggleEntry(user.id, plan.id, '2025-08-04', undefined); + await svc.toggleEntry(user.id, plan.id, '2025-08-04', undefined); + const result = await svc.toggleEntry(user.id, plan.id, '2025-08-04', undefined); expect(result.action).toBe('removed'); const row = testDb @@ -539,10 +541,10 @@ describe('toggleEntry', () => { expect(row).toBeUndefined(); }); - it('VACAY-SVC-033a: logs a half day when fraction is 0.5 (#552)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033a: logs a half day when fraction is 0.5 (#552)', async () => { + const { user, plan } = await setupUserWithPlan(); - const result = svc.toggleEntry(user.id, plan.id, '2025-08-05', 0.5); + const result = await svc.toggleEntry(user.id, plan.id, '2025-08-05', 0.5); expect(result).toMatchObject({ action: 'added', fraction: 0.5 }); const row = testDb @@ -551,11 +553,11 @@ describe('toggleEntry', () => { 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(); + it('VACAY-SVC-033b: converts a full day into a half day in place (action: updated)', async () => { + const { user, plan } = await setupUserWithPlan(); - svc.toggleEntry(user.id, plan.id, '2025-08-06', 1); - const result = svc.toggleEntry(user.id, plan.id, '2025-08-06', 0.5); + await svc.toggleEntry(user.id, plan.id, '2025-08-06', 1); + const result = await svc.toggleEntry(user.id, plan.id, '2025-08-06', 0.5); expect(result).toMatchObject({ action: 'updated', fraction: 0.5 }); const row = testDb @@ -564,11 +566,11 @@ describe('toggleEntry', () => { expect(row.fraction).toBe(0.5); }); - it('VACAY-SVC-033c: toggling the same half day again clears it (action: removed)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033c: toggling the same half day again clears it (action: removed)', async () => { + const { user, plan } = await 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); + await svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5); + const result = await svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5); expect(result.action).toBe('removed'); const row = testDb @@ -578,10 +580,10 @@ describe('toggleEntry', () => { }); // Weekend blocking (I-02): plans default to block_weekends = 1 / weekend_days '0,6'. - it('VACAY-SVC-033d: rejects a blocked weekend day with error weekend_blocked (I-02)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033d: rejects a blocked weekend day with error weekend_blocked (I-02)', async () => { + const { user, plan } = await setupUserWithPlan(); - const result = svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined); // Saturday + const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined); // Saturday expect(result).toEqual({ error: 'weekend_blocked' }); const row = testDb @@ -590,45 +592,45 @@ describe('toggleEntry', () => { expect(row).toBeUndefined(); }); - it('VACAY-SVC-033e: accepts a non-weekend day on a blocking plan', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033e: accepts a non-weekend day on a blocking plan', async () => { + const { user, plan } = await setupUserWithPlan(); - const result = svc.toggleEntry(user.id, plan.id, '2025-07-16', undefined); // Wednesday + const result = await svc.toggleEntry(user.id, plan.id, '2025-07-16', undefined); // Wednesday expect(result).toMatchObject({ action: 'added' }); }); - it('VACAY-SVC-033f: accepts a weekend day when block_weekends is off', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033f: accepts a weekend day when block_weekends is off', async () => { + const { user, plan } = await setupUserWithPlan(); testDb.prepare('UPDATE vacay_plans SET block_weekends = 0 WHERE id = ?').run(plan.id); - const result = svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined); // Saturday + const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined); // Saturday expect(result).toMatchObject({ action: 'added' }); }); - it('VACAY-SVC-033g: honours custom weekend_days (5,6 blocks Friday, frees Sunday)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033g: honours custom weekend_days (5,6 blocks Friday, frees Sunday)', async () => { + const { user, plan } = await setupUserWithPlan(); testDb.prepare("UPDATE vacay_plans SET weekend_days = '5,6' WHERE id = ?").run(plan.id); - expect(svc.toggleEntry(user.id, plan.id, '2025-07-18', undefined)).toEqual({ error: 'weekend_blocked' }); // Friday - expect(svc.toggleEntry(user.id, plan.id, '2025-07-20', undefined)).toMatchObject({ action: 'added' }); // Sunday + expect(await svc.toggleEntry(user.id, plan.id, '2025-07-18', undefined)).toEqual({ error: 'weekend_blocked' }); // Friday + expect(await svc.toggleEntry(user.id, plan.id, '2025-07-20', undefined)).toMatchObject({ action: 'added' }); // Sunday }); - it('VACAY-SVC-033h: a NULL weekend_days column falls back to Sat/Sun', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033h: a NULL weekend_days column falls back to Sat/Sun', async () => { + const { user, plan } = await setupUserWithPlan(); testDb.prepare('UPDATE vacay_plans SET weekend_days = NULL WHERE id = ?').run(plan.id); - expect(svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined)).toEqual({ error: 'weekend_blocked' }); // Saturday + expect(await svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined)).toEqual({ error: 'weekend_blocked' }); // Saturday }); - it('VACAY-SVC-033i: still removes an existing entry on a blocked day (stray-data cleanup)', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033i: still removes an existing entry on a blocked day (stray-data cleanup)', async () => { + const { user, plan } = await setupUserWithPlan(); testDb .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)') .run(plan.id, user.id, '2025-07-19', '', 1, 'vacation'); - const result = svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); + const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); expect(result.action).toBe('removed'); const row = testDb @@ -637,13 +639,13 @@ describe('toggleEntry', () => { expect(row).toBeUndefined(); }); - it('VACAY-SVC-033j: refuses to convert an existing entry in place on a blocked day', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-033j: refuses to convert an existing entry in place on a blocked day', async () => { + const { user, plan } = await setupUserWithPlan(); testDb .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)') .run(plan.id, user.id, '2025-07-19', '', 0.5, 'vacation'); - const result = svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); + const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); expect(result).toEqual({ error: 'weekend_blocked' }); const row = testDb @@ -656,10 +658,10 @@ describe('toggleEntry', () => { // ── toggleCompanyHoliday ────────────────────────────────────────────────────── describe('toggleCompanyHoliday', () => { - it('VACAY-SVC-034: adds a company holiday on first call (action: added)', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-034: adds a company holiday on first call (action: added)', async () => { + const { plan } = await setupUserWithPlan(); - const result = svc.toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined); + const result = await svc.toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined); expect(result.action).toBe('added'); const row = testDb @@ -668,11 +670,11 @@ describe('toggleCompanyHoliday', () => { expect(row).toBeDefined(); }); - it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', async () => { + const { plan } = await setupUserWithPlan(); - svc.toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined); - const result = svc.toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined); + await svc.toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined); + const result = await svc.toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined); expect(result.action).toBe('removed'); const row = testDb @@ -681,14 +683,14 @@ describe('toggleCompanyHoliday', () => { expect(row).toBeUndefined(); }); - it('VACAY-SVC-036: adding a company holiday removes any existing vacay_entry on that date', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-036: adding a company holiday removes any existing vacay_entry on that date', async () => { + const { user, plan } = await setupUserWithPlan(); // First add a personal entry on that date - svc.toggleEntry(user.id, plan.id, '2025-05-01', undefined); + await 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); + await svc.toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined); const personalEntry = testDb .prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?') @@ -700,13 +702,13 @@ describe('toggleCompanyHoliday', () => { // ── acceptInvite / declineInvite / cancelInvite ─────────────────────────────── describe('acceptInvite', () => { - it('VACAY-SVC-037: changes membership status to accepted', () => { - const { user: owner, plan: ownerPlan } = setupUserWithPlan(); + it('VACAY-SVC-037: changes membership status to accepted', async () => { + const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: invitee } = createUser(testDb); - svc.getOwnPlan(invitee.id); // ensure own plan exists for data migration path + await 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); + const result = await svc.acceptInvite(invitee.id, ownerPlan.id, undefined); expect(result.error).toBeUndefined(); const row = testDb @@ -715,35 +717,35 @@ describe('acceptInvite', () => { expect(row?.status).toBe('accepted'); }); - it('VACAY-SVC-038: returns 404 error when there is no pending invite', () => { + it('VACAY-SVC-038: returns 404 error when there is no pending invite', async () => { const { user } = createUser(testDb); - const result = svc.acceptInvite(user.id, 99999, undefined); + const result = await 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(); + it('VACAY-SVC-039: accepted member becomes visible via getActivePlan', async () => { + const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: invitee } = createUser(testDb); - svc.getOwnPlan(invitee.id); + await svc.getOwnPlan(invitee.id); insertMember(ownerPlan.id, invitee.id, 'pending'); - svc.acceptInvite(invitee.id, ownerPlan.id, undefined); + await svc.acceptInvite(invitee.id, ownerPlan.id, undefined); - const active = svc.getActivePlan(invitee.id); + const active = await 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(); + it('VACAY-SVC-040: removes the pending invite row', async () => { + const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: invitee } = createUser(testDb); insertMember(ownerPlan.id, invitee.id, 'pending'); - svc.declineInvite(invitee.id, ownerPlan.id, undefined); + await svc.declineInvite(invitee.id, ownerPlan.id, undefined); const row = testDb .prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') @@ -753,12 +755,12 @@ describe('declineInvite', () => { }); describe('cancelInvite', () => { - it('VACAY-SVC-041: removes the pending invite when owner cancels it', () => { - const { user: owner, plan: ownerPlan } = setupUserWithPlan(); + it('VACAY-SVC-041: removes the pending invite when owner cancels it', async () => { + const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: target } = createUser(testDb); insertMember(ownerPlan.id, target.id, 'pending'); - svc.cancelInvite(ownerPlan.id, target.id); + await svc.cancelInvite(ownerPlan.id, target.id); const row = testDb .prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') @@ -770,25 +772,25 @@ describe('cancelInvite', () => { // ── getAvailableUsers ───────────────────────────────────────────────────────── describe('getAvailableUsers', () => { - it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', () => { - const { user: owner, plan } = setupUserWithPlan(); + it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', async () => { + const { user: owner, plan } = await setupUserWithPlan(); const { user: unrelated } = createUser(testDb); - svc.getOwnPlan(unrelated.id); + await svc.getOwnPlan(unrelated.id); - const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; + const available = await 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(); + it('VACAY-SVC-043: excludes users who already have an accepted membership in any plan', async () => { + const { user: owner, plan } = await setupUserWithPlan(); const { user: alreadyFused } = createUser(testDb); - const { plan: otherPlan } = setupUserWithPlan(); + const { plan: otherPlan } = await setupUserWithPlan(); insertMember(otherPlan.id, alreadyFused.id, 'accepted'); - const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; + const available = await svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; expect(available.map(u => u.id)).not.toContain(alreadyFused.id); }); @@ -796,36 +798,36 @@ describe('getAvailableUsers', () => { // #2112 — guests are trip-scoped accounts, and every other directory in the app // already leaves them out. Vacay's two pickers did not, so a guest stayed // selectable here even after being removed from the trip it was created for. - it('VACAY-SVC-073: guest accounts are not offered in the plan invite picker', () => { - const { user: owner, plan } = setupUserWithPlan(); + it('VACAY-SVC-073: guest accounts are not offered in the plan invite picker', async () => { + const { user: owner, plan } = await setupUserWithPlan(); const { user: guest } = createUser(testDb); testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); - const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; + const available = await svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; expect(available.map(u => u.id)).not.toContain(guest.id); }); - it('VACAY-SVC-074: guest accounts are not offered in the shared-calendar picker', () => { - const { user: owner } = setupUserWithPlan(); + it('VACAY-SVC-074: guest accounts are not offered in the shared-calendar picker', async () => { + const { user: owner } = await setupUserWithPlan(); const { user: guest } = createUser(testDb); testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); - const available = svc.getShareAvailableUsers(owner.id) as { id: number }[]; + const available = await svc.getShareAvailableUsers(owner.id) as { id: number }[]; expect(available.map(u => u.id)).not.toContain(guest.id); }); - it('VACAY-SVC-075: a guest id sent straight to the write paths is refused', () => { - const { user: owner, plan } = setupUserWithPlan(); + it('VACAY-SVC-075: a guest id sent straight to the write paths is refused', async () => { + const { user: owner, plan } = await setupUserWithPlan(); const { user: guest } = createUser(testDb); testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); // The picker is only a list. The id comes back from the client, and the MCP // tools reach the same two methods, so refusing has to happen here. - const invited = svc.sendInvite(plan.id, owner.id, 'owner', 'owner@example.test', guest.id); + const invited = await svc.sendInvite(plan.id, owner.id, 'owner', 'owner@example.test', guest.id); expect(invited.error).toBe('User not found'); - const shared = svc.shareCalendar(owner.id, 'owner@example.test', guest.id); + const shared = await svc.shareCalendar(owner.id, 'owner@example.test', guest.id); expect(shared.error).toBe('User not found'); }); }); @@ -833,11 +835,11 @@ describe('getAvailableUsers', () => { // ── getStats ────────────────────────────────────────────────────────────────── describe('getStats', () => { - it('VACAY-SVC-044: returns per-user stats with correct fields', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-044: returns per-user stats with correct fields', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - const stats = svc.getStats(plan.id, yr); + const stats = await svc.getStats(plan.id, yr); expect(stats).toHaveLength(1); expect(stats[0]).toMatchObject({ @@ -849,109 +851,109 @@ describe('getStats', () => { }); }); - it('VACAY-SVC-045: used reflects the actual number of entries for that user and year', () => { - const { user, plan } = setupUserWithPlan(); + it('VACAY-SVC-045: used reflects the actual number of entries for that user and year', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); allowWeekends(plan.id); - svc.toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined); - svc.toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined); - const stats = svc.getStats(plan.id, yr); + const stats = await 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(); + it('VACAY-SVC-045a: half days count as 0.5 toward the used total (#552)', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); allowWeekends(plan.id); - 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 + await svc.toggleEntry(user.id, plan.id, `${yr}-09-12`, 1); // full day + await svc.toggleEntry(user.id, plan.id, `${yr}-09-13`, 0.5); // half day - const stats = svc.getStats(plan.id, yr); + const stats = await 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(); + it('VACAY-SVC-045b: comp/flex days cost nothing (#1074)', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); allowWeekends(plan.id); - svc.toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation'); - svc.toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp'); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation'); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp'); - const stats = svc.getStats(plan.id, yr); + const stats = await 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(); + it('VACAY-SVC-045c: a half comp day also costs nothing (#1074)', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); allowWeekends(plan.id); - svc.toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp'); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp'); - const stats = svc.getStats(plan.id, yr); + const stats = await 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(); + it('VACAY-SVC-045d: comp_used reports comp days separately, summing fractions (#1074)', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); allowWeekends(plan.id); - 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'); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-17`, 1, 'comp'); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-18`, 0.5, 'comp'); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-19`, 1, 'vacation'); - const stats = svc.getStats(plan.id, yr); + const stats = await 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(); + it('VACAY-SVC-045e: converting a vacation day to comp refunds it to the entitlement (#1074)', async () => { + const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); allowWeekends(plan.id); - svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation'); - expect(svc.getStats(plan.id, yr)[0].used).toBe(1); + await svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation'); + expect((await svc.getStats(plan.id, yr))[0].used).toBe(1); - const result = svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'comp'); + const result = await 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); + expect((await svc.getStats(plan.id, yr))[0].used).toBe(0); }); - it('VACAY-SVC-045g: a rejected weekend toggle leaves used/remaining untouched (I-02)', () => { - const { user, plan } = setupUserWithPlan(); - svc.toggleEntry(user.id, plan.id, '2025-07-16', 1, 'vacation'); // Wednesday - const before = svc.getStats(plan.id, 2025)[0]; + it('VACAY-SVC-045g: a rejected weekend toggle leaves used/remaining untouched (I-02)', async () => { + const { user, plan } = await setupUserWithPlan(); + await svc.toggleEntry(user.id, plan.id, '2025-07-16', 1, 'vacation'); // Wednesday + const before = (await svc.getStats(plan.id, 2025))[0]; expect(before.used).toBe(1); - const result = svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); // Saturday + const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); // Saturday expect(result).toEqual({ error: 'weekend_blocked' }); - const after = svc.getStats(plan.id, 2025)[0]; + const after = (await svc.getStats(plan.id, 2025))[0]; expect(after.used).toBe(before.used); expect(after.remaining).toBe(before.remaining); }); - 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 }); + it('VACAY-SVC-045f: getStats reports the window it counted over (#737)', async () => { + const { user, plan } = await setupUserWithPlan(); + await svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); - const stats = svc.getStats(plan.id, 2026); + const stats = await svc.getStats(plan.id, 2026); expect(stats[0]).toMatchObject({ window_start: '2026-07-01', window_end: '2027-07-01' }); }); @@ -960,161 +962,161 @@ describe('getStats', () => { // ── Configurable vacation year (#737) ───────────────────────────────────────── describe('resolveYearWindow', () => { - it('VACAY-SVC-045g: defaults to the plain calendar year when nothing is configured', () => { - const { user } = setupUserWithPlan(); + it('VACAY-SVC-045g: defaults to the plain calendar year when nothing is configured', async () => { + const { user } = await setupUserWithPlan(); - expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(await 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' }); + it('VACAY-SVC-045h: an explicit calendar setting resolves identically', async () => { + const { user } = await setupUserWithPlan(); + await svc.updateYearSettings(user.id, { year_type: 'calendar' }); - expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(await 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 }); + it('VACAY-SVC-045i: a fiscal year starts on the configured month and day', async () => { + const { user } = await setupUserWithPlan(); + await 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' }); + expect(await 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' }); + it('VACAY-SVC-045j: an anniversary year follows the hire date month and day', async () => { + const { user } = await setupUserWithPlan(); + await 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' }); + expect(await 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' }); + it('VACAY-SVC-045k: an anniversary year without a hire date falls back to January 1', async () => { + const { user } = await setupUserWithPlan(); + await svc.updateYearSettings(user.id, { year_type: 'anniversary' }); - expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(await 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(); + it('VACAY-SVC-045k1: an anniversary year ignores a month left behind by a previous fiscal setting', async () => { + const { user } = await 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 }); + await svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 }); + await 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' }); + expect(await 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' }); + it('VACAY-SVC-045k2: a Feb 29 hire date resolves to Feb 28, a boundary every year has', async () => { + const { user } = await setupUserWithPlan(); + await 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' }); + expect(await 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 }); + it('VACAY-SVC-045k3: a fiscal day the month cannot have is clamped down to one it can', async () => { + const { user } = await setupUserWithPlan(); + await 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' }); + expect(await 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 }); + it('VACAY-SVC-045l: consecutive periods meet exactly, so the carry-over chain stays intact', async () => { + const { user } = await setupUserWithPlan(); + await 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); + expect((await svc.resolveYearWindow(user.id, 2025)).end).toBe((await svc.resolveYearWindow(user.id, 2026)).start); }); }); describe('updateUserYearSettings', () => { - it('VACAY-SVC-045m: upserts, so a second call replaces the first', () => { - const { user } = setupUserWithPlan(); + it('VACAY-SVC-045m: upserts, so a second call replaces the first', async () => { + const { user } = await 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' }); + await svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); + const saved = await 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(); + it('VACAY-SVC-045n: clamps an out-of-range month and day instead of storing them', async () => { + const { user } = await setupUserWithPlan(); - const saved = svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 99, year_start_day: 0 }); + const saved = await 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(); + it('VACAY-SVC-045o: drops a malformed hire date rather than persisting it', async () => { + const { user } = await setupUserWithPlan(); - const saved = svc.updateYearSettings(user.id, { year_type: 'anniversary', hire_date: 'not-a-date' }); + const saved = await 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(); + it('VACAY-SVC-045p: an unknown year type falls back to calendar', async () => { + const { user } = await setupUserWithPlan(); - expect(svc.updateYearSettings(user.id, { year_type: 'quarterly' }).year_type).toBe('calendar'); + expect((await 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(); + it('VACAY-SVC-045q: fills in the calendar defaults for a user who never configured anything', async () => { + const { user } = await setupUserWithPlan(); - expect(svc.getUserYearSettings(user.id)).toBeUndefined(); - expect(svc.getYearSettings(user.id)).toEqual({ + expect(await svc.getUserYearSettings(user.id)).toBeUndefined(); + expect(await 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(); + it('VACAY-SVC-045r: a calendar user is always in the period named after today’s year', async () => { + const { user } = await setupUserWithPlan(); - expect(svc.currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2026); + expect(await 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 }); + it('VACAY-SVC-045s: before a fiscal year starts, today still belongs to the previous period', async () => { + const { user } = await setupUserWithPlan(); + await 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); + expect(await svc.currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2025); + expect(await 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 }); + it('VACAY-SVC-045t: a day in the next calendar year still counts toward the fiscal period', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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 + await svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside, first half + await svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, second half - expect(svc.getStats(plan.id, 2026)[0].used).toBe(2); + expect((await 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 }); + it('VACAY-SVC-045u: days outside the window belong to the neighbouring periods, not this one', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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 + await svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // last day of the previous period + await 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); + expect((await svc.getStats(plan.id, 2026))[0].used).toBe(0); + expect((await svc.getStats(plan.id, 2025))[0].used).toBe(1); + expect((await 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 }); + it('VACAY-SVC-045v: carry-over is computed over the previous period, not the previous calendar year', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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) @@ -1122,10 +1124,10 @@ describe('usage over a shifted window (#737)', () => { `).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-02', 1, 'vacation'); - svc.toggleEntry(user.id, plan.id, '2031-02-03', 1, 'vacation'); + await svc.toggleEntry(user.id, plan.id, '2030-09-02', 1, 'vacation'); + await svc.toggleEntry(user.id, plan.id, '2031-02-03', 1, 'vacation'); - svc.addYear(plan.id, 2031, undefined); + await 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') @@ -1133,18 +1135,18 @@ describe('usage over a shifted window (#737)', () => { 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 }); + it('VACAY-SVC-045w: comp days are excluded from the carry-over of a shifted period too', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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-03', 1, 'comp'); + await svc.toggleEntry(user.id, plan.id, '2031-02-03', 1, 'comp'); - svc.addYear(plan.id, 2031, undefined); + await 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') @@ -1152,16 +1154,16 @@ describe('usage over a shifted window (#737)', () => { 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); + it('VACAY-SVC-045x: deleteYear clears the entries of the period, spanning both calendar years', async () => { + const { user, plan } = await setupUserWithPlan(); + await svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + await 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 + await svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside + await svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, next calendar year + await svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period, must survive - svc.deleteYear(plan.id, 2026, undefined); + await svc.deleteYear(plan.id, 2026, undefined); const left = testDb .prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date') @@ -1171,40 +1173,40 @@ describe('usage over a shifted window (#737)', () => { }); 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 }); + it('VACAY-SVC-045y: returns both calendar halves of a shifted period for the viewer', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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 + await svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); + await svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); + await svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period - const result = svc.getEntries(plan.id, '2026', user.id); + const result = await 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 }); + it('VACAY-SVC-045z: without a viewer it stays on the plain calendar year (MCP reads)', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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'); + await svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); + await svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); - const result = svc.getEntries(plan.id, '2026'); + const result = await 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 }); + it('VACAY-SVC-045aa: a start day past the 1st still loads the whole first month, since the grid renders it', async () => { + const { user, plan } = await setupUserWithPlan(); + await 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 + await 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); + expect(((await svc.getEntries(plan.id, '2026', user.id)).entries as unknown[])).toHaveLength(1); + expect((await svc.getStats(plan.id, 2026))[0].used).toBe(0); + expect((await svc.getStats(plan.id, 2025))[0].used).toBe(1); }); }); @@ -1212,7 +1214,7 @@ describe('getEntries over a window (#737)', () => { describe('applyHolidayCalendars', () => { it('VACAY-SVC-046: does nothing when holidays_enabled is 0 (fetch is never called)', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await setupUserWithPlan(); // holidays_enabled defaults to 0 await svc.applyHolidayCalendars(plan.id); @@ -1221,12 +1223,12 @@ describe('applyHolidayCalendars', () => { }); it('VACAY-SVC-047: deletes matching vacay_entries for a global holiday date returned by the API', async () => { - const { user, plan } = setupUserWithPlan(); + const { user, plan } = await 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); + await svc.addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined); // Add a vacay entry on the holiday date const holidayDate = `${yr}-01-01`; @@ -1252,11 +1254,11 @@ describe('applyHolidayCalendars', () => { // ── Read-only calendar shares (#444/#667) ───────────────────────────────────── describe('shareCalendar', () => { - it('VACAY-SVC-048: inserts a share row and returns no error', () => { - const { user: owner } = setupUserWithPlan(); + it('VACAY-SVC-048: inserts a share row and returns no error', async () => { + const { user: owner } = await setupUserWithPlan(); const { user: target } = createUser(testDb); - const result = svc.shareCalendar(owner.id, owner.email, target.id); + const result = await svc.shareCalendar(owner.id, owner.email, target.id); expect(result.error).toBeUndefined(); const row = testDb @@ -1265,50 +1267,50 @@ describe('shareCalendar', () => { expect(row).toBeDefined(); }); - it('VACAY-SVC-049: returns 400 when sharing with yourself', () => { - const { user: owner } = setupUserWithPlan(); + it('VACAY-SVC-049: returns 400 when sharing with yourself', async () => { + const { user: owner } = await setupUserWithPlan(); - const result = svc.shareCalendar(owner.id, owner.email, owner.id); + const result = await 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(); + it('VACAY-SVC-050: returns 404 when the target user does not exist', async () => { + const { user: owner } = await setupUserWithPlan(); - const result = svc.shareCalendar(owner.id, owner.email, 99999); + const result = await 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(); + it('VACAY-SVC-051: returns 400 when the share already exists', async () => { + const { user: owner } = await setupUserWithPlan(); const { user: target } = createUser(testDb); - svc.shareCalendar(owner.id, owner.email, target.id); + await svc.shareCalendar(owner.id, owner.email, target.id); - const result = svc.shareCalendar(owner.id, owner.email, target.id); + const result = await 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(); + it('VACAY-SVC-052: returns 400 when the target is already a member of the owner plan', async () => { + const { user: owner, plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); insertMember(plan.id, member.id, 'accepted'); - const result = svc.shareCalendar(owner.id, owner.email, member.id); + const result = await 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(); + it('VACAY-SVC-053: outgoing rows carry the target user info', async () => { + const { user: owner } = await setupUserWithPlan(); const { user: target } = createUser(testDb); - svc.shareCalendar(owner.id, owner.email, target.id); + await svc.shareCalendar(owner.id, owner.email, target.id); - const result = svc.listShares(owner.id); + const result = await svc.listShares(owner.id); expect(result.outgoing).toHaveLength(1); expect(result.outgoing[0]).toMatchObject({ @@ -1319,13 +1321,13 @@ describe('listShares', () => { 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); + it('VACAY-SVC-054: incoming rows carry the owner info, their color and a boolean hidden flag', async () => { + const { user: owner, plan } = await setupUserWithPlan(); + await svc.setUserColor(owner.id, plan.id, '#ef4444', undefined); + const { user: viewer } = await setupUserWithPlan(); + await svc.shareCalendar(owner.id, owner.email, viewer.id); - const result = svc.listShares(viewer.id); + const result = await svc.listShares(viewer.id); expect(result.outgoing).toEqual([]); expect(result.incoming).toHaveLength(1); @@ -1338,14 +1340,14 @@ describe('listShares', () => { 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); + it('VACAY-SVC-055: remaps colors when two sharing owners sit on the default indigo', async () => { + const { user: viewer } = await setupUserWithPlan(); // viewer's own color is #6366f1 + const { user: owner1 } = await setupUserWithPlan(); // default #6366f1 + const { user: owner2 } = await setupUserWithPlan(); // default #6366f1 + await svc.shareCalendar(owner1.id, owner1.email, viewer.id); + await svc.shareCalendar(owner2.id, owner2.email, viewer.id); - const { incoming } = svc.listShares(viewer.id); + const { incoming } = await svc.listShares(viewer.id); expect(incoming).toHaveLength(2); // Both collide with the viewer's own indigo, so each gets a distinct free preset @@ -1356,80 +1358,80 @@ describe('listShares', () => { }); describe('removeShare', () => { - it('VACAY-SVC-056: the owner can revoke their share', () => { - const { user: owner } = setupUserWithPlan(); + it('VACAY-SVC-056: the owner can revoke their share', async () => { + const { user: owner } = await 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; + await svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = (await svc.listShares(owner.id)).outgoing[0].id as number; - expect(svc.removeShare(shareId, owner.id)).toBe(true); + expect(await 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(); + it('VACAY-SVC-057: the recipient can remove a share they received', async () => { + const { user: owner } = await setupUserWithPlan(); const { user: viewer } = createUser(testDb); - svc.shareCalendar(owner.id, owner.email, viewer.id); - const shareId = svc.listShares(viewer.id).incoming[0].id; + await svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = (await svc.listShares(viewer.id)).incoming[0].id; - expect(svc.removeShare(shareId, viewer.id)).toBe(true); + expect(await 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(); + it('VACAY-SVC-058: a third user cannot remove the share, unknown ids return false', async () => { + const { user: owner } = await 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; + await svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = (await svc.listShares(owner.id)).outgoing[0].id as number; - expect(svc.removeShare(shareId, stranger.id)).toBe(false); + expect(await 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); + expect(await 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(); + it('VACAY-SVC-059: the recipient can hide and unhide the shared calendar', async () => { + const { user: owner } = await setupUserWithPlan(); const { user: viewer } = createUser(testDb); - svc.shareCalendar(owner.id, owner.email, viewer.id); - const shareId = svc.listShares(viewer.id).incoming[0].id; + await svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = (await svc.listShares(viewer.id)).incoming[0].id; - expect(svc.setShareHidden(shareId, viewer.id, true)).toBe(true); + expect(await 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((await svc.listShares(viewer.id)).incoming[0].hidden).toBe(true); - expect(svc.setShareHidden(shareId, viewer.id, false)).toBe(true); + expect(await 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(); + it('VACAY-SVC-060: the owner cannot toggle the recipient hidden flag', async () => { + const { user: owner } = await 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; + await svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = (await svc.listShares(owner.id)).outgoing[0].id as number; - expect(svc.setShareHidden(shareId, owner.id, true)).toBe(false); + expect(await 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(); + it('VACAY-SVC-061: excludes self, already-shared users and plan members', async () => { + const { user: owner, plan } = await 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); + await 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); + const ids = (await svc.getShareAvailableUsers(owner.id) as { id: number }[]).map(u => u.id); expect(ids).toContain(unrelated.id); expect(ids).not.toContain(owner.id); @@ -1439,17 +1441,17 @@ describe('getShareAvailableUsers', () => { }); describe('getSharedCalendars', () => { - it('VACAY-SVC-062: returns only the owner entries of the shared plan, including fractions', () => { - const { user: owner, plan } = setupUserWithPlan(); + it('VACAY-SVC-062: returns only the owner entries of the shared plan, including fractions', async () => { + const { user: owner, plan } = await 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 { user: viewer } = await setupUserWithPlan(); + await svc.toggleEntry(owner.id, plan.id, '2025-06-10', 1); + await svc.toggleEntry(owner.id, plan.id, '2025-06-11', 0.5); + await svc.toggleEntry(member.id, plan.id, '2025-06-12', 1); + await svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = svc.getSharedCalendars(viewer.id, '2025'); + const calendars = await svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].owner_id).toBe(owner.id); @@ -1461,36 +1463,36 @@ describe('getSharedCalendars', () => { ]); }); - it('VACAY-SVC-063: company holidays stay hidden while the owner plan has them disabled', () => { - const { user: owner, plan } = setupUserWithPlan(); + it('VACAY-SVC-063: company holidays stay hidden while the owner plan has them disabled', async () => { + const { user: owner, plan } = await 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); + await svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); + await svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = svc.getSharedCalendars(viewer.id, '2025'); + const calendars = await 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(); + it('VACAY-SVC-064: company holidays appear once the owner plan enables them', async () => { + const { user: owner, plan } = await 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); + await svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); + await svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = svc.getSharedCalendars(viewer.id, '2025'); + const calendars = await 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)', () => { + it('VACAY-SVC-065: an owner without any plan yields empty arrays (no lazy creation)', async () => { 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'); + const calendars = await svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].entries).toEqual([]); @@ -1499,16 +1501,16 @@ describe('getSharedCalendars', () => { expect(plan).toBeUndefined(); }); - it('VACAY-SVC-066: follows an owner fused into another plan', () => { - const { user: host, plan: hostPlan } = setupUserWithPlan(); + it('VACAY-SVC-066: follows an owner fused into another plan', async () => { + const { user: host, plan: hostPlan } = await setupUserWithPlan(); const { user: owner } = createUser(testDb); - svc.getOwnPlan(owner.id); + await 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); + await svc.toggleEntry(owner.id, hostPlan.id, '2025-03-03', 1); + await svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = svc.getSharedCalendars(viewer.id, '2025'); + const calendars = await svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].entries).toEqual([{ date: '2025-03-03', fraction: 1, kind: 'vacation' }]); @@ -1519,37 +1521,37 @@ describe('getSharedCalendars', () => { describe('quirk fixes', () => { /** A DatabaseService whose run() throws when the SQL matches, for atomicity checks. */ - function failingService(match: string) { + async 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()); + return new VacayService(failingDb, new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); } - it('VACAY-SVC-068: acceptInvite is atomic — a failure mid-flow rolls the status flip back', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-068: acceptInvite is atomic — a failure mid-flow rolls the status flip back', async () => { + const { plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); - svc.getOwnPlan(member.id); + await 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 broken = await failingService('INSERT OR IGNORE INTO vacay_user_years'); + await expect(broken.acceptInvite(member.id, plan.id, undefined)).rejects.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(); + it('VACAY-SVC-069: deleteYear is atomic — a failure mid-flow keeps the year and its entries', async () => { + const { user, plan } = await setupUserWithPlan(); const year = new Date().getFullYear(); allowWeekends(plan.id); - svc.toggleEntry(user.id, plan.id, `${year}-03-03`, 1); + await 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'); + const broken = await failingService('DELETE FROM vacay_user_years'); + await expect(broken.deleteYear(plan.id, year, undefined)).rejects.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(); @@ -1558,7 +1560,7 @@ describe('quirk fixes', () => { 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()); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' }); // Nothing cached: a retry hits the network again. @@ -1570,7 +1572,7 @@ describe('quirk fixes', () => { it('VACAY-SVC-070a: getHolidays refuses a year or country that is not a plain code', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); for (const [year, country] of [['../../..', 'DE'], ['2026', 'DE/../../x'], ['20xx', 'DE'], ['2026', 'DEU']]) { expect(await fresh.getHolidays(year, country)).toEqual({ error: 'Failed to fetch holidays' }); @@ -1581,7 +1583,7 @@ describe('quirk fixes', () => { it('VACAY-SVC-070b: getSchoolHolidayRegions refuses a country that is not an alpha-2 code', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); expect(await fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR')).toEqual({ error: 'Failed to fetch school holiday regions', @@ -1596,7 +1598,7 @@ describe('quirk fixes', () => { json: async () => [{ date: '2026-01-01' }], }); vi.stubGlobal('fetch', fetchMock); - const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' }); expect(await fresh.getHolidays('2026', 'DE')).toEqual({ error: 'Failed to fetch holidays' }); @@ -1622,19 +1624,19 @@ describe('quirk fixes', () => { }; }); vi.stubGlobal('fetch', fetchMock); - const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); expect(await fresh.getHolidays('2026', 'DE')).toEqual({ error: 'Failed to fetch holidays' }); expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' }); }); it('VACAY-SVC-071: applyHolidayCalendars honors the cache TTL', async () => { - const { plan } = setupUserWithPlan(); + const { plan } = await 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()); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb)); await fresh.applyHolidayCalendars(plan.id); const afterFirst = fetchMock.mock.calls.length; @@ -1652,14 +1654,14 @@ describe('quirk fixes', () => { } }); - it('VACAY-SVC-072: addYear still no-ops on a duplicate year but propagates real errors', () => { - const { plan } = setupUserWithPlan(); + it('VACAY-SVC-072: addYear still no-ops on a duplicate year but propagates real errors', async () => { + const { plan } = await 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); + expect(await 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'); + const broken = await failingService('INSERT OR IGNORE INTO vacay_user_years'); + await expect(broken.addYear(plan.id, year + 1, undefined)).rejects.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(); });