diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index b220b8206..a48bcb987 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -316,11 +316,11 @@ export class AdminController { } @Get('oauth-sessions') - listOAuthSessions() { return { sessions: this.oauth.listAllOAuthSessions() }; } + async listOAuthSessions() { return { sessions: await this.oauth.listAllOAuthSessions() }; } @Delete('oauth-sessions/:id') async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - ok(this.oauth.adminRevokeOAuthSession(id)); + ok(await this.oauth.adminRevokeOAuthSession(id)); await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) }); return { success: true }; } diff --git a/server/src/nest/backup/backup.impl.ts b/server/src/nest/backup/backup.impl.ts index 685a11acc..727b62b5c 100644 --- a/server/src/nest/backup/backup.impl.ts +++ b/server/src/nest/backup/backup.impl.ts @@ -518,7 +518,7 @@ export async function restoreFromZip(storage: StorageService, zipPath: string): // entirely when reopen failed: with no live DB handle the registry has // nothing to read, and the restore is already reported as "restart // required" below — rehydrating into a stale/guessed config would be worse. - storage.reloadConfig(); + await storage.reloadConfig(); const extractedUploads = path.join(extractDir, 'uploads'); if (fs.existsSync(extractedUploads)) { diff --git a/server/src/nest/categories/categories.mcp.ts b/server/src/nest/categories/categories.mcp.ts index a8e360438..0b08815f9 100644 --- a/server/src/nest/categories/categories.mcp.ts +++ b/server/src/nest/categories/categories.mcp.ts @@ -69,7 +69,7 @@ export class CategoriesMcp { async createCategory({ name, color, icon }: { name: string; color?: string; icon?: string }, ctx: McpContext) { if (await this.isDemoUser(ctx.userId)) return demoDenied(); // The palette is instance-wide; the REST route restricts management to admins. Match it. - if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); + if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); const category = await this.categories.create(ctx.userId, name, color, icon); return ok({ category }); } @@ -88,7 +88,7 @@ export class CategoriesMcp { }) async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) { if (await this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); + if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); if (!(await this.categories.getById(categoryId))) return errorResult('Category not found'); const category = await this.categories.update(categoryId, name, color, icon); return ok({ category }); @@ -105,7 +105,7 @@ export class CategoriesMcp { }) async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) { if (await this.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); + if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); if (!(await this.categories.getById(categoryId))) return errorResult('Category not found'); await this.categories.remove(categoryId); return ok({ success: true }); diff --git a/server/src/nest/llm-parse/llm-config.resolver.ts b/server/src/nest/llm-parse/llm-config.resolver.ts index 56cf43ce1..bda49ba1e 100644 --- a/server/src/nest/llm-parse/llm-config.resolver.ts +++ b/server/src/nest/llm-parse/llm-config.resolver.ts @@ -30,10 +30,10 @@ export class LlmConfigResolver { */ async resolve(userId: number): Promise { if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null; - return this.readInstanceConfig() ?? (await this.readUserConfig(userId)); + return (await this.readInstanceConfig()) ?? (await this.readUserConfig(userId)); } - private readInstanceConfig(): ResolvedLlmConfig | null { + private async readInstanceConfig(): Promise { const row = this.dbService.get<{ config?: string } | undefined>( 'SELECT config FROM addons WHERE id = ?', ADDON_IDS.LLM_PARSING, diff --git a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts index 5e10db571..f4d585261 100644 --- a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts +++ b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts @@ -74,7 +74,7 @@ export class McpToolGuardsService { } /** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */ - isAdminUser(userId: number): boolean { + async isAdminUser(userId: number): Promise { const userRow = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', userId); return userRow?.role === 'admin'; } diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index 4ff1d8d21..187d3365b 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -125,7 +125,7 @@ export class McpTransportService { private readonly registry: McpRegistryService, ) {} - verifyToken(authHeader: string | undefined): VerifyTokenResult | null { + async verifyToken(authHeader: string | undefined): Promise { if (!authHeader) return null; // M8: strictly require "Bearer" scheme (RFC 6750) const spaceIdx = authHeader.indexOf(' '); @@ -136,7 +136,7 @@ export class McpTransportService { // OAuth 2.1 access token (trekoa_...) if (token.startsWith('trekoa_')) { - const result = this.oauth.getUserByAccessToken(token); + const result = await this.oauth.getUserByAccessToken(token); if (!result) return null; // RFC 8707: audience must always match this resource endpoint. // Pre-audit tokens with audience=null are revoked by the SEC-H6 migration. @@ -164,7 +164,7 @@ export class McpTransportService { return; } - const tokenResult = this.verifyToken(req.headers['authorization']); + const tokenResult = await this.verifyToken(req.headers['authorization']); if (!tokenResult) { setAuthChallenge(res); res.status(401).json({ error: 'Access token required' }); diff --git a/server/src/nest/oauth/oauth-api.controller.ts b/server/src/nest/oauth/oauth-api.controller.ts index e6b0a40cb..5ac8ccfcb 100644 --- a/server/src/nest/oauth/oauth-api.controller.ts +++ b/server/src/nest/oauth/oauth-api.controller.ts @@ -122,7 +122,7 @@ export class OauthApiController { @UseGuards(JwtAuthGuard) async listClients(@CurrentUser() user: User) { await this.requireMcp403(); - return { clients: this.oauth.listOAuthClients(user.id) }; + return { clients: await this.oauth.listOAuthClients(user.id) }; } @Post('clients') @@ -164,7 +164,7 @@ export class OauthApiController { @UseGuards(JwtAuthGuard) async listSessions(@CurrentUser() user: User) { await this.requireMcp403(); - return { sessions: this.oauth.listOAuthSessions(user.id) }; + return { sessions: await this.oauth.listOAuthSessions(user.id) }; } @Delete('sessions/:id') diff --git a/server/src/nest/oauth/oauth-public.controller.ts b/server/src/nest/oauth/oauth-public.controller.ts index 6d9c74302..ccd325537 100644 --- a/server/src/nest/oauth/oauth-public.controller.ts +++ b/server/src/nest/oauth/oauth-public.controller.ts @@ -67,14 +67,14 @@ export class OauthPublicController { if (pending.clientId !== client_id) return invalidGrant('client_id_mismatch', pending.userId); if (pending.redirectUri !== redirect_uri) return invalidGrant('redirect_uri_mismatch', pending.userId); if (pending.resource && resource && pending.resource !== stripTrailingSlashes(resource)) return invalidGrant('resource_mismatch', pending.userId); - if (!this.oauth.authenticateClient(client_id, client_secret)) { + if (!(await this.oauth.authenticateClient(client_id, client_secret))) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); return; } if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId); - const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null); + const tokens = await this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null); await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); res.json(tokens); return; @@ -100,7 +100,7 @@ export class OauthPublicController { res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' }); return; } - const client = this.oauth.authenticateClient(client_id, client_secret); + const client = await this.oauth.authenticateClient(client_id, client_secret); if (!client) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); @@ -126,7 +126,7 @@ export class OauthPublicController { grantedScopes = allowedScopes; } const audience = resource ? stripTrailingSlashes(resource) : `${stripTrailingSlashes(this.oauth.mcpSafeUrl())}/mcp`; - const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience); + const tokens = await this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience); await this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip }); res.json(tokens); return; @@ -143,7 +143,7 @@ export class OauthPublicController { res.status(401).json({ error: 'invalid_token' }); return; } - const info = this.oauth.getUserByAccessToken(auth.slice(7)); + const info = await this.oauth.getUserByAccessToken(auth.slice(7)); if (!info) { res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"'); res.status(401).json({ error: 'invalid_token' }); @@ -166,7 +166,7 @@ export class OauthPublicController { res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' }); return; } - if (!this.oauth.authenticateClient(client_id, client_secret)) { + if (!(await this.oauth.authenticateClient(client_id, client_secret))) { logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`); await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip }); res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); diff --git a/server/src/nest/oauth/oauth-sdk.provider.ts b/server/src/nest/oauth/oauth-sdk.provider.ts index 1f2147584..bfb66ac75 100644 --- a/server/src/nest/oauth/oauth-sdk.provider.ts +++ b/server/src/nest/oauth/oauth-sdk.provider.ts @@ -44,7 +44,7 @@ function assertValidRedirectUris(uris: string[]): void { // Row → SDK client info shape // --------------------------------------------------------------------------- -function rowToInfo(row: NonNullable>): OAuthClientInformationFull { +function rowToInfo(row: NonNullable>>): OAuthClientInformationFull { return { client_id: row.client_id, client_name: row.name, @@ -65,7 +65,7 @@ export class TrekClientsStore implements OAuthRegisteredClientsStore { constructor(private readonly oauth: OauthService) {} async getClient(clientId: string): Promise { - const row = this.oauth.getSdkClient(clientId); + const row = await this.oauth.getSdkClient(clientId); return row ? rowToInfo(row) : undefined; } @@ -178,7 +178,7 @@ export class TrekOAuthProvider implements OAuthServerProvider { if (!codeVerifier || !this.oauth.verifyPKCE(codeVerifier, pending.codeChallenge)) throw new Error('Authorization grant is invalid.'); - const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null); + const tokens = await this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null); await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', @@ -200,7 +200,7 @@ export class TrekOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { - const info = this.oauth.getUserByAccessToken(token); + const info = await this.oauth.getUserByAccessToken(token); if (!info) throw new Error('Invalid or expired token'); return { token, diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 6d81e33a3..a8fe81c73 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -92,7 +92,7 @@ export class OauthService { // Client management (self-service, gated by MCP addon) // ------------------------------------------------------------------------- - listOAuthClients(userId: number): Record[] { + async listOAuthClients(userId: number): Promise[]> { const rows = this.db.all( 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC', userId, @@ -246,7 +246,7 @@ export class OauthService { // Consent management // ------------------------------------------------------------------------- - getConsent(clientId: string, userId: number): string[] | null { + async getConsent(clientId: string, userId: number): Promise { const row = this.db.get<{ scopes: string }>( 'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?', clientId, userId, ); @@ -255,7 +255,7 @@ export class OauthService { async saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): Promise { // Union existing consent with newly approved scopes (M5: never narrow stored consent) - const existing = this.getConsent(clientId, userId) ?? []; + const existing = (await this.getConsent(clientId, userId)) ?? []; const merged = Array.from(new Set([...existing, ...scopes])); this.db.run( 'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', @@ -272,19 +272,19 @@ export class OauthService { // Token issuance // ------------------------------------------------------------------------- - issueTokens( + async issueTokens( clientId: string, userId: number, scopes: string[], parentTokenId: number | null = null, audience: string | null = null, - ): { + ): Promise<{ access_token: string; refresh_token: string; token_type: 'Bearer'; expires_in: number; scope: string; - } { + }> { const rawAccess = generateAccessToken(); const rawRefresh = generateRefreshToken(); const accessHash = hashToken(rawAccess); @@ -316,17 +316,17 @@ export class OauthService { * can never be presented as a valid refresh token (same precedent as public * client secret hashes stored in client_secret_hash). */ - issueClientCredentialsToken( + async issueClientCredentialsToken( clientId: string, userId: number, scopes: string[], audience: string, - ): { + ): Promise<{ access_token: string; token_type: 'Bearer'; expires_in: number; scope: string; - } { + }> { const rawAccess = generateAccessToken(); const accessHash = hashToken(rawAccess); const placeholderHash = randomBytes(32).toString('hex'); @@ -354,21 +354,21 @@ export class OauthService { /** SDK clients-store read: the exact row shape the MCP SDK adapter * (oauth-sdk.provider.ts) maps to OAuthClientInformationFull. */ - getSdkClient(clientId: string): { + async getSdkClient(clientId: string): Promise<{ client_id: string; name: string; redirect_uris: string; allowed_scopes: string; is_public: number; created_via: string; - } | undefined { + } | undefined> { return this.db.get( 'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?', clientId, ); } - getUserByAccessToken(rawToken: string): OAuthTokenInfo | null { + async getUserByAccessToken(rawToken: string): Promise { const hash = hashToken(rawToken); const row = this.db.get(` SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at, @@ -395,7 +395,7 @@ export class OauthService { // ------------------------------------------------------------------------- /** Walk parent_token_id upward to find the root token id of this rotation chain. */ - private findChainRoot(tokenId: number): number { + private async findChainRoot(tokenId: number): Promise { let current = tokenId; for (let i = 0; i < 100; i++) { const row = this.db.get<{ id: number; parent_token_id: number | null }>('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?', current); @@ -406,7 +406,7 @@ export class OauthService { } /** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */ - private revokeChain(rootId: number): number[] { + private async revokeChain(rootId: number): Promise { const rows = this.db.all<{ id: number }>(` WITH RECURSIVE chain(id) AS ( SELECT id FROM oauth_tokens WHERE id = ? @@ -435,7 +435,7 @@ export class OauthService { * explicit revoke leaves no live child, and a chain revoked after a real replay * has every child revoked with it, so neither can slip through here. */ - private isConcurrentRotation(row: OAuthTokenRow): boolean { + private async isConcurrentRotation(row: OAuthTokenRow): Promise { const revokedAt = parseSqliteUtc(row.revoked_at); if (!revokedAt) return false; if (Date.now() - revokedAt.getTime() > REFRESH_ROTATION_GRACE_MS) return false; @@ -451,7 +451,7 @@ export class OauthService { clientId: string, clientSecret: string | undefined, ip?: string | null, - ): Promise<{ error?: string; status?: number; tokens?: ReturnType }> { + ): Promise<{ error?: string; status?: number; tokens?: Awaited> }> { const client = this.db.get('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId); if (!client) return { error: 'invalid_client', status: 401 }; if (!client.is_public) { @@ -476,8 +476,8 @@ export class OauthService { // not theft (#1007): they share one token, both post it, and the loser used // to take the whole chain down with it. Issue a sibling pair off the same // parent so each client walks away with its own token. - if (this.isConcurrentRotation(row)) { - const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); + if (await this.isConcurrentRotation(row)) { + const tokens = await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', @@ -488,8 +488,8 @@ export class OauthService { } // A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain. - const rootId = this.findChainRoot(row.id); - this.revokeChain(rootId); + const rootId = await this.findChainRoot(row.id); + await this.revokeChain(rootId); revokeUserSessionsForClient(row.user_id, clientId); @@ -514,7 +514,7 @@ export class OauthService { // MCP connections (#1475). this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id); - const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); + const tokens = await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip }); return { tokens }; @@ -550,7 +550,7 @@ export class OauthService { // Active session listing (for user settings page) // ------------------------------------------------------------------------- - listOAuthSessions(userId: number): Record[] { + async listOAuthSessions(userId: number): Promise[]> { const rows = this.db.all>(` SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at @@ -659,7 +659,7 @@ export class OauthService { return { valid: true, loginRequired: true }; } - const existingConsent = this.getConsent(params.client_id, userId); + const existingConsent = await this.getConsent(params.client_id, userId); const consentRequired = !existingConsent || !this.isConsentSufficient(existingConsent, grantedScopes); return { @@ -692,7 +692,7 @@ export class OauthService { // Client authentication (for token endpoint) // ------------------------------------------------------------------------- - authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null { + async authenticateClient(clientId: string, clientSecret: string | undefined): Promise { const client = this.db.get('SELECT * FROM oauth_clients WHERE client_id = ?', clientId); if (!client) return null; if (client.is_public) { @@ -711,7 +711,7 @@ export class OauthService { // panel route is under /api/admin. The route keeps its path and its guard. // --------------------------------------------------------------------------- - listAllOAuthSessions() { + async listAllOAuthSessions() { const rows = this.db.all & { scopes: string }>(` SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username, ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at @@ -734,7 +734,7 @@ export class OauthService { }); } - adminRevokeOAuthSession(id: string) { + async adminRevokeOAuthSession(id: string) { const row = this.db.get<{ id: number; user_id: number; client_id: string }>( 'SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?', id, ); diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index e85c44393..2f17ee32b 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -481,7 +481,7 @@ export class PackingMcp { if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Templates are global; the REST route restricts saving to admins. Match it. - if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); + if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName); if (!template) return errorResult('Nothing to save — the packing list is empty.'); return ok({ template }); @@ -500,7 +500,7 @@ export class PackingMcp { async deletePackingTemplate({ templateId }: { templateId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Templates are global; the REST route restricts management to admins. Match it. - if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); + if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); const result = this.packing.deletePackingTemplate(String(templateId)); if ('error' in result) return errorResult(result.error); return ok({ success: true, name: result.name }); diff --git a/server/src/nest/place-shadow/place-shadow.controller.ts b/server/src/nest/place-shadow/place-shadow.controller.ts index 6b0d5dd8c..28ace148b 100644 --- a/server/src/nest/place-shadow/place-shadow.controller.ts +++ b/server/src/nest/place-shadow/place-shadow.controller.ts @@ -29,26 +29,26 @@ export class PlaceShadowController { */ @Post('pick') @HttpCode(200) - pick(@Body() body: PlaceShadowPickDto): PlaceShadowPickResult { - return { recorded: this.shadow.record(body) }; + async pick(@Body() body: PlaceShadowPickDto): Promise { + return { recorded: await this.shadow.record(body) }; } @Get('summary') @UseGuards(JwtAuthGuard, AdminGuard) - summary(): PlaceShadowSummaryResult { - return this.shadow.summary(); + async summary(): Promise { + return await this.shadow.summary(); } @Get('export') @UseGuards(JwtAuthGuard, AdminGuard) - export(@Query('after') after?: string): PlaceShadowExportResult { + async export(@Query('after') after?: string): Promise { const parsed = Number(after); - return this.shadow.export(Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined); + return await this.shadow.export(Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined); } @Delete() @UseGuards(JwtAuthGuard, AdminGuard) - clear(): { removed: number } { - return { removed: this.shadow.clear() }; + async clear(): Promise<{ removed: number }> { + return { removed: await this.shadow.clear() }; } } diff --git a/server/src/nest/place-shadow/place-shadow.job.ts b/server/src/nest/place-shadow/place-shadow.job.ts index 5a2612f99..96d937f3e 100644 --- a/server/src/nest/place-shadow/place-shadow.job.ts +++ b/server/src/nest/place-shadow/place-shadow.job.ts @@ -19,14 +19,17 @@ export class PlaceShadowRetentionJob implements OnApplicationBootstrap { private readonly registrar: CronRegistrarService, ) {} - onApplicationBootstrap(): void { + // `async` only so the sweep's call-graph gate sees a non-sync DB-reaching + // frame here: nothing in the body awaits, and Nest awaits the returned + // promise before the next bootstrap hook, exactly as it did the `void`. + async onApplicationBootstrap(): Promise { if (!this.registrar.isEnabled()) return; this.registrar.register('place-shadow-retention', '40 3 * * *', () => this.tick()); } - tick(): void { + async tick(): Promise { try { - const removed = this.shadow.purgeExpired(); + const removed = await this.shadow.purgeExpired(); if (removed > 0) { logInfo(`Place shadow retention: removed ${removed} expired row(s)`); } diff --git a/server/src/nest/place-shadow/place-shadow.service.ts b/server/src/nest/place-shadow/place-shadow.service.ts index ec52a069b..b33e58b2a 100644 --- a/server/src/nest/place-shadow/place-shadow.service.ts +++ b/server/src/nest/place-shadow/place-shadow.service.ts @@ -74,7 +74,7 @@ function toRow(r: DbRow): PlaceShadowRow { export class PlaceShadowService { constructor(private readonly db: DatabaseService) {} - enabled(): boolean { + async enabled(): Promise { const row = this.db.get<{ value: string }>( "SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'", ); @@ -87,8 +87,8 @@ export class PlaceShadowService { * somebody adds a place would be noise about a feature that is off on * purpose. */ - record(pick: PlaceShadowPickRequest): boolean { - if (!this.enabled()) return false; + async record(pick: PlaceShadowPickRequest): Promise { + if (!(await this.enabled())) return false; // A rank outside the returned list means the client and the server disagree // about what was on screen. Storing it would poison the very number the // corpus exists to produce, so the row is dropped instead. @@ -119,7 +119,7 @@ export class PlaceShadowService { * previous page; paging by id rather than by offset keeps the pages stable * while new rows arrive underneath. */ - export(after?: number, limit = EXPORT_PAGE_SIZE): PlaceShadowExportResult { + async export(after?: number, limit = EXPORT_PAGE_SIZE): Promise { const size = Math.min(Math.max(1, limit), EXPORT_PAGE_SIZE); const rows = this.db.all( `SELECT * FROM place_shadow_picks @@ -138,8 +138,8 @@ export class PlaceShadowService { }; } - summary(): PlaceShadowSummaryResult { - const enabled = this.enabled(); + async summary(): Promise { + const enabled = await this.enabled(); const totals = this.db.get<{ total: number; oldest: string | null; newest: string | null }>( 'SELECT COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM place_shadow_picks', ); @@ -181,12 +181,12 @@ export class PlaceShadowService { } /** Admin wipe. Returns how many rows went. */ - clear(): number { + async clear(): Promise { return this.db.run('DELETE FROM place_shadow_picks').changes; } /** Nightly retention. Returns how many rows went. */ - purgeExpired(retentionDays = RETENTION_DAYS): number { + async purgeExpired(retentionDays = RETENTION_DAYS): Promise { return this.db.run( `DELETE FROM place_shadow_picks WHERE created_at < datetime('now', ?)`, diff --git a/server/src/nest/route-usage/route-usage.controller.ts b/server/src/nest/route-usage/route-usage.controller.ts index b026c901a..6f1879fad 100644 --- a/server/src/nest/route-usage/route-usage.controller.ts +++ b/server/src/nest/route-usage/route-usage.controller.ts @@ -25,19 +25,19 @@ export class RouteUsageController { */ @Post('report') @HttpCode(200) - report(@Body() body: RouteUsageReportDto): RouteUsageReportResult { - return { recorded: this.usage.record(body) }; + async report(@Body() body: RouteUsageReportDto): Promise { + return { recorded: await this.usage.record(body) }; } @Get('summary') @UseGuards(JwtAuthGuard, AdminGuard) - summary(): RouteUsageSummaryResult { - return this.usage.summary(); + async summary(): Promise { + return await this.usage.summary(); } @Delete() @UseGuards(JwtAuthGuard, AdminGuard) - clear(): { removed: number } { - return { removed: this.usage.clear() }; + async clear(): Promise<{ removed: number }> { + return { removed: await this.usage.clear() }; } } diff --git a/server/src/nest/route-usage/route-usage.job.ts b/server/src/nest/route-usage/route-usage.job.ts index 7019482a9..8d2260e48 100644 --- a/server/src/nest/route-usage/route-usage.job.ts +++ b/server/src/nest/route-usage/route-usage.job.ts @@ -20,14 +20,17 @@ export class RouteUsageRetentionJob implements OnApplicationBootstrap { private readonly registrar: CronRegistrarService, ) {} - onApplicationBootstrap(): void { + // `async` only so the sweep's call-graph gate sees a non-sync DB-reaching + // frame here: nothing in the body awaits, and Nest awaits the returned + // promise before the next bootstrap hook, exactly as it did the `void`. + async onApplicationBootstrap(): Promise { if (!this.registrar.isEnabled()) return; this.registrar.register('route-usage-retention', '45 3 * * *', () => this.tick()); } - tick(): void { + async tick(): Promise { try { - const removed = this.usage.purgeExpired(); + const removed = await this.usage.purgeExpired(); if (removed > 0) { logInfo(`Route usage retention: removed ${removed} expired row(s)`); } diff --git a/server/src/nest/route-usage/route-usage.service.ts b/server/src/nest/route-usage/route-usage.service.ts index 79897c805..336016472 100644 --- a/server/src/nest/route-usage/route-usage.service.ts +++ b/server/src/nest/route-usage/route-usage.service.ts @@ -7,6 +7,7 @@ import type { RouteUsageSurface, } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; /** Days a counted day survives. Aggregates are tiny, so this is a year and a bit. */ export const RETENTION_DAYS = 400; @@ -40,9 +41,12 @@ interface DbRow { */ @Injectable() export class RouteUsageService { - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + private readonly uow: UnitOfWork, + ) {} - enabled(): boolean { + async enabled(): Promise { const row = this.db.get<{ value: string }>( "SELECT value FROM app_settings WHERE key = 'route_usage_enabled'", ); @@ -56,11 +60,11 @@ export class RouteUsageService { * switched-off instance answers 200 rather than an error the client would log on * every flush. */ - record(report: RouteUsageReportRequest): boolean { - if (!this.enabled()) return false; + async record(report: RouteUsageReportRequest): Promise { + if (!(await this.enabled())) return false; // One transaction for the batch: a flush is a handful of rows, and a partial // one would leave a day counted twice on the client's next retry. - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const entry of report.entries) { this.db.run( `INSERT INTO route_usage_daily (day, profile, surface, self_hosted, requests, waypoints, km, failed) @@ -83,7 +87,7 @@ export class RouteUsageService { return true; } - rows(): RouteUsageDayRow[] { + async rows(): Promise { const rows = this.db.all( 'SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface', ); @@ -99,9 +103,9 @@ export class RouteUsageService { })); } - summary(): RouteUsageSummaryResult { - const rows = this.rows(); - const enabled = this.enabled(); + async summary(): Promise { + const rows = await this.rows(); + const enabled = await this.enabled(); const empty: RouteUsageSummaryResult = { enabled, retentionDays: RETENTION_DAYS, @@ -169,7 +173,7 @@ export class RouteUsageService { } /** Removes days past the retention window. Returns how many rows went. */ - purgeExpired(): number { + async purgeExpired(): Promise { const result = this.db.run( `DELETE FROM route_usage_daily WHERE day < date('now', ?)`, `-${RETENTION_DAYS} days`, @@ -178,7 +182,7 @@ export class RouteUsageService { } /** Wipes every counter. The admin's own "start over". */ - clear(): number { + async clear(): Promise { const result = this.db.run('DELETE FROM route_usage_daily'); return result.changes ?? 0; } diff --git a/server/src/nest/school-holidays/school-holidays.mcp.ts b/server/src/nest/school-holidays/school-holidays.mcp.ts index 7155410ed..49b12d63b 100644 --- a/server/src/nest/school-holidays/school-holidays.mcp.ts +++ b/server/src/nest/school-holidays/school-holidays.mcp.ts @@ -11,7 +11,7 @@ export class SchoolHolidaysMcp { constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {} private async adminWrite(ctx: McpContext, write: () => unknown) { - if (!this.guards.isAdminUser(ctx.userId)) return adminRequired(); + if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); try { return ok(await write()); } catch (error) { if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message); diff --git a/server/src/nest/storage/storage-admin.controller.ts b/server/src/nest/storage/storage-admin.controller.ts index 8b5ec3774..89640ac0d 100644 --- a/server/src/nest/storage/storage-admin.controller.ts +++ b/server/src/nest/storage/storage-admin.controller.ts @@ -37,14 +37,14 @@ export class StorageAdminController { ) {} @Get() - get() { - return this.service.state(); + async get() { + return await this.service.state(); } @Put() async update(@CurrentUser() user: User, @Body() body: StorageConfigDto, @Req() req: Request) { try { - this.service.applyConfig(body); + await this.service.applyConfig(body); } catch (err) { // The conflict branch must come before the blanket 400: a // StorageConflictError IS an Error, so the generic catch-all below @@ -127,7 +127,7 @@ export class StorageAdminController { async migrationStart(@CurrentUser() user: User, @Body() body: StorageMigrationRequestDto, @Req() req: Request): Promise<{ started: true }> { const { category, to } = body; try { - this.service.startMigration(category, to); + await this.service.startMigration(category, to); } catch (err) { if (err instanceof MigrationRequestError) throw new HttpException({ error: err.message }, 400); if (err instanceof MigrationTargetError) throw new HttpException({ error: err.message }, 404); diff --git a/server/src/nest/storage/storage-admin.service.ts b/server/src/nest/storage/storage-admin.service.ts index 090fac9c6..9365939b3 100644 --- a/server/src/nest/storage/storage-admin.service.ts +++ b/server/src/nest/storage/storage-admin.service.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { Injectable } from '@nestjs/common'; import type { StorageAdminState, StorageBackend, StorageConfigPut, StorageTestResponse, StorageUsage } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { BACKENDS_KEY, CATEGORIES_KEY, @@ -37,10 +38,11 @@ export class StorageAdminService { private readonly storage: StorageService, private readonly jobs: StorageJobsService, private readonly stats: StorageStatsService, + private readonly uow: UnitOfWork, ) {} /** The effective world — secrets masked, categories cross-referenced per backend. */ - state(): StorageAdminState { + async state(): Promise { const snapshot = this.registry.snapshot(); const assignments = Object.entries(snapshot.categories) as Array< [keyof typeof snapshot.categories, { backend: string; source: 'default' | 'settings' }] @@ -56,10 +58,10 @@ export class StorageAdminService { categories: snapshot.categories, health: { replicaFailures: this.storage.health().replicaFailures.map((f) => ({ ...f })) }, seedFilePresent: fs.existsSync(SEED_CONFIG_PATH), - usage: this.stats.readUsage(), + usage: await this.stats.readUsage(), backfills: this.jobs.statuses(), migrations: this.jobs.migrationStatuses(), - version: this.registry.currentConfigVersion(), + version: await this.registry.currentConfigVersion(), configError: this.registry.lastLoadError(), }; } @@ -82,8 +84,8 @@ export class StorageAdminService { * job registry; throws MigrationRequestError (400) / MigrationTargetError (404) / * BackfillBusyError (409). */ - startMigration(category: StorageCategory, to: string): void { - this.jobs.startMigration(category, to); + async startMigration(category: StorageCategory, to: string): Promise { + await this.jobs.startMigration(category, to); } /** True when an active migration was cancelled; false when there was nothing to cancel. */ @@ -92,8 +94,8 @@ export class StorageAdminService { } /** Runs and persists a fresh usage scan. Throws StatsBusyError (409) if one is already running. */ - refreshStats(): Promise { - return this.stats.scan(); + async refreshStats(): Promise { + return await this.stats.scan(); } /** @@ -104,19 +106,19 @@ export class StorageAdminService { * config that moved on since the form was loaded (e.g. a category * migration's flip, which bumps the same counter). */ - applyConfig(config: StorageConfigPut): void { - const currentVersion = this.registry.currentConfigVersion(); + async applyConfig(config: StorageConfigPut): Promise { + const currentVersion = await this.registry.currentConfigVersion(); if (config.version !== currentVersion) { throw new StorageConflictError(currentVersion, config.version); } - const unmasked = unmaskStorageConfig(config, this.storedBackendsRow()); + const unmasked = unmaskStorageConfig(config, await this.storedBackendsRow()); // unmask only resolves the secret fields it knows about; a mask sentinel // submitted in a non-secret field would otherwise pass through untouched // and get persisted verbatim as garbage-in. assertNoMaskSentinels(unmasked); this.registry.preview({ backends: unmasked.backends, categories: unmasked.categories }); const encrypted = encryptStorageSecrets(unmasked); - this.db.transaction(() => { + await this.uow.transactional(async () => { const upsert = this.db.prepare( 'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', ); @@ -124,7 +126,7 @@ export class StorageAdminService { upsert.run(CATEGORIES_KEY, JSON.stringify(encrypted.categories)); upsert.run(VERSION_KEY, String(currentVersion + 1)); }); - this.registry.reload(); + await this.registry.reload(); // Any running job whose backend the reloaded config no longer has ends // cancelled rather than running invisibly against a stale driver ref // (polish item 3) — see StorageJobsService.cancelJobsForMissingBackends. @@ -139,7 +141,7 @@ export class StorageAdminService { async testBackend(candidate: StorageBackend): Promise { const { backends } = unmaskStorageConfig( { backends: [candidate], categories: {} }, - this.storedBackendsRow(), + await this.storedBackendsRow(), ); const backend = backends[0]!; const targets = this.probeTargetsFor(backend).map(decryptBackendSecrets) as Array< @@ -180,7 +182,7 @@ export class StorageAdminService { } /** The raw stored backends row — the unmask source (tolerates absent/garbage rows). */ - private storedBackendsRow(): unknown { + private async storedBackendsRow(): Promise { const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', BACKENDS_KEY); if (!row?.value) return []; try { diff --git a/server/src/nest/storage/storage-jobs.service.ts b/server/src/nest/storage/storage-jobs.service.ts index 6554b20ae..e3a58c458 100644 --- a/server/src/nest/storage/storage-jobs.service.ts +++ b/server/src/nest/storage/storage-jobs.service.ts @@ -147,7 +147,7 @@ export class StorageJobsService { * Throws MigrationRequestError (400) / MigrationTargetError (404) / * BackfillBusyError (409, shared with backfills — one storage job at a time). */ - startMigration(category: StorageCategory, to: string): void { + async startMigration(category: StorageCategory, to: string): Promise { if (!STORAGE_CATEGORIES.includes(category)) { throw new MigrationRequestError(`'${category}' is not a configurable category`); } @@ -318,13 +318,16 @@ export class StorageJobsService { // last object's await (or an empty category, which never enters the loop // at all) would otherwise reach the flip unchecked. Everything from here // to assignCategory() below is one synchronous stretch, so this is the - // last point a cancel can still be honored before the flip. + // last point a cancel can still be honored before the flip. (The flip + // itself now awaits — assignCategory writes through UnitOfWork — but + // nothing between this check and that call yields, so the window is the + // same one it always was.) if (job.cancelled) { job.status = { ...s(), status: 'cancelled', finishedAt: Date.now() }; return; } // Phase 2: flip — the job, not the save, owns this. - this.registry.assignCategory(s().category, s().to); + await this.registry.assignCategory(s().category, s().to); // Phase 3: delta sweep + reclaimable tally. Cancellation is ignored here // (bounded). Reclaimable stays SOURCE-keyed — it counts what's left to // reclaim on the old backend, not where it landed on the new one. diff --git a/server/src/nest/storage/storage-registry.service.ts b/server/src/nest/storage/storage-registry.service.ts index 11f8f5384..de5ef0529 100644 --- a/server/src/nest/storage/storage-registry.service.ts +++ b/server/src/nest/storage/storage-registry.service.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import { STORAGE_BACKEND_TYPES, storageConfigSchema } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { LocalDriver } from './drivers/local.driver'; @@ -133,16 +134,17 @@ export class StorageRegistryService implements OnModuleInit { private readonly db: DatabaseService, private readonly env: RuntimeEnvService, private readonly events: StorageEventsService, + private readonly uow: UnitOfWork, ) {} - onModuleInit(): void { - this.seedFromFileOnce(); - this.load(true); + async onModuleInit(): Promise { + await this.seedFromFileOnce(); + await this.load(true); } /** Re-read settings, validate, atomically swap. In-flight ops keep their resolved instances. */ - reload(): void { - this.load(false); + async reload(): Promise { + await this.load(false); } /** @@ -200,8 +202,8 @@ export class StorageRegistryService implements OnModuleInit { } /** Current optimistic-concurrency counter — 0 when never bumped (fresh install). */ - currentConfigVersion(): number { - return readConfigVersion(this.db); + async currentConfigVersion(): Promise { + return await readConfigVersion(this.db); } /** Non-null when the last load() fell back (last-good config or built-in defaults) — null once a load succeeds. */ @@ -218,7 +220,7 @@ export class StorageRegistryService implements OnModuleInit { * PUT built against the pre-flip version now conflicts instead of silently * overwriting this assignment. */ - assignCategory(category: StorageCategory, backend: string): void { + async assignCategory(category: StorageCategory, backend: string): Promise { // Belt-and-braces alongside the migration job's own pre-flip cancel guard: // never persist a category pointing at a backend that doesn't exist in // the current snapshot (e.g. a config save removed it mid-migration). @@ -240,17 +242,17 @@ export class StorageRegistryService implements OnModuleInit { `cannot assign '${category}' to '${backend}' — '${owner}' is a mirror replica of '${holder.name}', and that mirror's sync sweep would delete the category's objects`, ); } - const stored = new Map(parseCategoryMap(this.readSettings().categories)); + const stored = new Map(parseCategoryMap((await this.readSettings()).categories)); stored.set(category, backend); const next = Object.fromEntries(stored); - this.db.transaction(() => { + await this.uow.transactional(async () => { const upsert = this.db.prepare( 'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', ); upsert.run(CATEGORIES_KEY, JSON.stringify(next)); - upsert.run(VERSION_KEY, String(readConfigVersion(this.db) + 1)); + upsert.run(VERSION_KEY, String((await readConfigVersion(this.db)) + 1)); }); - this.reload(); + await this.reload(); } recordReplicaFailure(failure: ReplicaFailure): void { @@ -275,7 +277,7 @@ export class StorageRegistryService implements OnModuleInit { * Recovery: stop the server, DELETE FROM app_settings WHERE key LIKE * 'storage.%', restart (documented in the README with slice 3). */ - private seedFromFileOnce(): void { + private async seedFromFileOnce(): Promise { const rowCount = this.db.get<{ n: number }>( 'SELECT COUNT(*) AS n FROM app_settings WHERE key IN (?, ?)', BACKENDS_KEY, @@ -313,7 +315,7 @@ export class StorageRegistryService implements OnModuleInit { return fail(err instanceof Error ? err.message : String(err)); } const encrypted = encryptStorageSecrets(config); - this.db.transaction(() => { + await this.uow.transactional(async () => { const upsert = this.db.prepare( 'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', ); @@ -330,9 +332,9 @@ export class StorageRegistryService implements OnModuleInit { * state is kept (at boot, when there is no previous state, the built-in * defaults — which cannot be misconfigured — are loaded instead). */ - private load(boot: boolean): void { + private async load(boot: boolean): Promise { try { - this.state = this.build(this.readSettings(), boot); + this.state = this.build(await this.readSettings(), boot); this.loadFailure = null; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -345,7 +347,7 @@ export class StorageRegistryService implements OnModuleInit { } } - private readSettings(): { backends: unknown; categories: unknown } { + private async readSettings(): Promise<{ backends: unknown; categories: unknown }> { const read = (key: string): unknown => { const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key); if (!row?.value) return undefined; @@ -480,7 +482,7 @@ function isRecord(value: unknown): value is Record { } /** Raw read of the version counter row — 0 for an absent/garbage row (fresh install, or a hand-edited DB). */ -function readConfigVersion(db: DatabaseService): number { +async function readConfigVersion(db: DatabaseService): Promise { const row = db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', VERSION_KEY); const parsed = row?.value ? Number.parseInt(row.value, 10) : 0; return Number.isFinite(parsed) ? parsed : 0; diff --git a/server/src/nest/storage/storage-stats.service.ts b/server/src/nest/storage/storage-stats.service.ts index f36da67e7..65a537b0e 100644 --- a/server/src/nest/storage/storage-stats.service.ts +++ b/server/src/nest/storage/storage-stats.service.ts @@ -55,7 +55,7 @@ export class StorageStatsService { } /** The stored row, parsed; null when absent or unparseable (logged, never a 500). */ - readUsage(): StorageUsage | null { + async readUsage(): Promise { const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', USAGE_KEY); if (!row) return null; try { diff --git a/server/src/nest/storage/storage-usage-scan.job.ts b/server/src/nest/storage/storage-usage-scan.job.ts index 956a6ce52..0215fd5f3 100644 --- a/server/src/nest/storage/storage-usage-scan.job.ts +++ b/server/src/nest/storage/storage-usage-scan.job.ts @@ -12,7 +12,10 @@ export class StorageUsageScanJob implements OnApplicationBootstrap { private readonly stats: StorageStatsService, ) {} - onApplicationBootstrap(): void { + // `async` only so the sweep's call-graph gate sees a non-sync DB-reaching + // frame here: nothing in the body awaits, and Nest awaits the returned + // promise before the next bootstrap hook, exactly as it did the `void`. + async onApplicationBootstrap(): Promise { if (!this.registrar.isEnabled()) return; this.registrar.register('storage-usage-scan', '15 4 * * *', () => { void this.tick(); diff --git a/server/src/nest/storage/storage.service.ts b/server/src/nest/storage/storage.service.ts index 4c4f21e65..b6ae8291a 100644 --- a/server/src/nest/storage/storage.service.ts +++ b/server/src/nest/storage/storage.service.ts @@ -290,8 +290,8 @@ export class StorageService { * pre-restore one. Same shape as health(): StorageRegistryService stays * unexported from the module, this facade is the only way in. */ - reloadConfig(): void { - this.registry.reload(); + async reloadConfig(): Promise { + await this.registry.reload(); } private resolve(category: ServedCategory, name: string): Resolved { diff --git a/server/src/nest/todo/todo.controller.ts b/server/src/nest/todo/todo.controller.ts index 244c929e9..50c0b6c06 100644 --- a/server/src/nest/todo/todo.controller.ts +++ b/server/src/nest/todo/todo.controller.ts @@ -39,38 +39,38 @@ export class TodoController { @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { items: this.todo.listItems(tripId) }; + async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { items: await this.todo.listItems(tripId) }; } @RequirePermission('packing_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: TodoCreateItemDto, @Headers('x-socket-id') socketId?: string, ) { const { name, category, due_date, description, assigned_user_id, priority } = body; - const item = this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); + const item = await this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); this.todo.broadcast(tripId, 'todo:created', { item }, socketId); return { item }; } @RequirePermission('packing_edit') @Put('reorder') - reorder( + async reorder( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: TodoReorderDto, ) { - this.todo.reorderItems(tripId, body.orderedIds); + await this.todo.reorderItems(tripId, body.orderedIds); return { success: true }; } @RequirePermission('packing_edit') @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -80,7 +80,7 @@ export class TodoController { const { name, checked, category, due_date, description, assigned_user_id, priority } = body; // bodyKeys carries which keys the request actually provided (the null-clear // protocol); the parsed body only ever holds known schema keys. - const updated = this.todo.updateItem( + const updated = await this.todo.updateItem( tripId, id, // checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds. @@ -96,13 +96,13 @@ export class TodoController { @RequirePermission('packing_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - if (!this.todo.deleteItem(tripId, id)) { + if (!(await this.todo.deleteItem(tripId, id))) { throw new HttpException({ error: 'Item not found' }, 404); } this.todo.broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, socketId); @@ -110,13 +110,13 @@ export class TodoController { } @Get('category-assignees') - categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { assignees: this.todo.getCategoryAssignees(tripId) }; + async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { assignees: await this.todo.getCategoryAssignees(tripId) }; } @RequirePermission('packing_edit') @Put('category-assignees/:categoryName') - updateCategoryAssignees( + async updateCategoryAssignees( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('categoryName') categoryName: string, @@ -124,7 +124,7 @@ export class TodoController { @Headers('x-socket-id') socketId?: string, ) { const category = decodeURIComponent(categoryName); - const rows = this.todo.updateCategoryAssignees(tripId, category, body.user_ids); + const rows = await this.todo.updateCategoryAssignees(tripId, category, body.user_ids); this.todo.broadcast(tripId, 'todo:assignees', { category, assignees: rows }, socketId); return { assignees: rows }; } diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 398b4328c..3711e5773 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -50,8 +50,8 @@ export class TodoMcp { access: { group: 'todos', mode: 'read' }, }) async listTodos({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - const items = this.todos.listItems(tripId); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + const items = await this.todos.listItems(tripId); return ok({ items }); } @@ -78,9 +78,9 @@ export class TodoMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const item = this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); + const item = await this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); this.guards.safeBroadcast(tripId, 'todo:created', { item }); return ok({ item }); } @@ -109,7 +109,7 @@ export class TodoMcp { ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Build bodyKeys to signal which nullable fields were explicitly provided const bodyKeys: string[] = []; @@ -117,7 +117,7 @@ export class TodoMcp { if (description !== undefined) bodyKeys.push('description'); if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id'); if (priority !== undefined) bodyKeys.push('priority'); - const item = this.todos.updateItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys); + const item = await this.todos.updateItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys); if (!item) return errorResult('To-do item not found.'); this.guards.safeBroadcast(tripId, 'todo:updated', { item }); return ok({ item }); @@ -137,9 +137,9 @@ export class TodoMcp { }) async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const item = this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); + const item = await this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); if (!item) return errorResult('To-do item not found.'); this.guards.safeBroadcast(tripId, 'todo:updated', { item }); return ok({ item }); @@ -158,9 +158,9 @@ export class TodoMcp { }) async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const deleted = this.todos.deleteItem(tripId, itemId); + const deleted = await this.todos.deleteItem(tripId, itemId); if (!deleted) return errorResult('To-do item not found.'); this.guards.safeBroadcast(tripId, 'todo:deleted', { itemId }); return ok({ success: true }); @@ -179,9 +179,9 @@ export class TodoMcp { }) async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - this.todos.reorderItems(tripId, orderedIds); + await this.todos.reorderItems(tripId, orderedIds); return ok({ success: true }); } @@ -196,8 +196,8 @@ export class TodoMcp { access: { group: 'todos', mode: 'read' }, }) async getTodoCategoryAssignees({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - const assignees = this.todos.getCategoryAssignees(tripId); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + const assignees = await this.todos.getCategoryAssignees(tripId); return ok({ assignees }); } @@ -215,9 +215,9 @@ export class TodoMcp { }) async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const assignees = this.todos.updateCategoryAssignees(tripId, categoryName, userIds); + const assignees = await this.todos.updateCategoryAssignees(tripId, categoryName, userIds); this.guards.safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees }); return ok({ assignees }); } @@ -232,7 +232,7 @@ export class TodoMcp { }) async tripTodosResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.todos.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.todos.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -241,7 +241,7 @@ export class TodoMcp { }], }; } - const items = this.todos.listItems(id); + const items = await this.todos.listItems(id); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/todo/todo.rpc.ts b/server/src/nest/todo/todo.rpc.ts index 207a2a62e..9b2fc9b94 100644 --- a/server/src/nest/todo/todo.rpc.ts +++ b/server/src/nest/todo/todo.rpc.ts @@ -27,8 +27,12 @@ export class TodoRpc { ) {} @PluginMethod('todos.list', { permission: 'db:read:todos' }) - list(params: Record, ctx: PluginRpcContext): unknown[] { - return this.guards.tripRead(params, ctx, () => this.todos.listItems(String(num(params.tripId, 'tripId'))) as unknown[]); + async list(params: Record, ctx: PluginRpcContext): Promise { + return await this.guards.tripRead( + params, + ctx, + async () => (await this.todos.listItems(String(num(params.tripId, 'tripId')))) as unknown[], + ); } @PluginMethod('todos.create', { permission: 'db:write:todos' }) @@ -38,7 +42,7 @@ export class TodoRpc { const input = asPayload(params.input); if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('todo name is required'); await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); - const item = this.todos.createItem(String(tripId), input as never); + const item = await this.todos.createItem(String(tripId), input as never); this.realtime.broadcast(tripId, 'todo:created', { item }, undefined); return item; } @@ -50,7 +54,7 @@ export class TodoRpc { const actor = this.guards.requireActor(ctx, 'todo'); await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); const input = asPayload(params.input); - const updated = this.todos.updateItem(String(tripId), String(todoId), input as never, Object.keys(input)); + const updated = await this.todos.updateItem(String(tripId), String(todoId), input as never, Object.keys(input)); if (!updated) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`); this.realtime.broadcast(tripId, 'todo:updated', { item: updated }, undefined); return updated; @@ -62,7 +66,7 @@ export class TodoRpc { const todoId = num(params.todoId, 'todoId'); const actor = this.guards.requireActor(ctx, 'todo'); await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION); - if (!this.todos.deleteItem(String(tripId), String(todoId))) { + if (!(await this.todos.deleteItem(String(tripId), String(todoId)))) { throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`); } this.realtime.broadcast(tripId, 'todo:deleted', { itemId: todoId }, undefined); diff --git a/server/src/nest/todo/todo.service.ts b/server/src/nest/todo/todo.service.ts index 849963e5a..ad467417a 100644 --- a/server/src/nest/todo/todo.service.ts +++ b/server/src/nest/todo/todo.service.ts @@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { User } from '../../types'; import { DatabaseService, type TripAccess } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; type Trip = TripAccess; @@ -22,9 +23,10 @@ export class TodoService { private readonly db: DatabaseService, private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, + private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } @@ -36,14 +38,14 @@ export class TodoService { this.realtime.broadcast(tripId, event, payload, socketId); } - listItems(tripId: string | number) { + async listItems(tripId: string | number) { return this.db.all( 'SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC', tripId ); } - createItem(tripId: string | number, data: { + async createItem(tripId: string | number, data: { name: string; category?: string | null; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number; }) { const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?', tripId)!; @@ -58,7 +60,7 @@ export class TodoService { return this.db.get('SELECT * FROM todo_items WHERE id = ?', result.lastInsertRowid); } - updateItem( + async updateItem( tripId: string | number, id: string | number, data: { name?: string; checked?: number; category?: string | null; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number | null }, @@ -96,7 +98,7 @@ export class TodoService { return this.db.get('SELECT * FROM todo_items WHERE id = ?', id); } - deleteItem(tripId: string | number, id: string | number): boolean { + async deleteItem(tripId: string | number, id: string | number): Promise { const item = this.db.get('SELECT id FROM todo_items WHERE id = ? AND trip_id = ?', id, tripId); if (!item) return false; @@ -104,16 +106,16 @@ export class TodoService { return true; } - reorderItems(tripId: string | number, orderedIds: number[]): void { + async reorderItems(tripId: string | number, orderedIds: number[]): Promise { const update = this.db.prepare('UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { orderedIds.forEach((id, index) => { update.run(index, id, tripId); }); }); } - getCategoryAssignees(tripId: string | number) { + async getCategoryAssignees(tripId: string | number) { const rows = this.db.all<{ category_name: string; user_id: number; username: string; avatar: string | null }>(` SELECT tca.category_name, tca.user_id, u.username, u.avatar FROM todo_category_assignees tca @@ -130,8 +132,8 @@ export class TodoService { return assignees; } - updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) { - this.db.transaction(() => { + async updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) { + await this.uow.transactional(async () => { this.db.run('DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?', tripId, categoryName); if (Array.isArray(userIds) && userIds.length > 0) { diff --git a/server/src/nest/trip-invite/trip-invite.controller.ts b/server/src/nest/trip-invite/trip-invite.controller.ts index f78a7099f..3bb098a41 100644 --- a/server/src/nest/trip-invite/trip-invite.controller.ts +++ b/server/src/nest/trip-invite/trip-invite.controller.ts @@ -24,7 +24,7 @@ export class TripInviteLinkController { constructor(private readonly invites: TripInviteService, private readonly audit: AuditService) {} private async requireManage(tripId: string, user: User) { - const trip = this.invites.verifyTripAccess(tripId, user.id); + const trip = await this.invites.verifyTripAccess(tripId, user.id); if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); if (!(await this.invites.canManage(trip, user))) throw new HttpException({ error: 'No permission' }, 403); } @@ -34,7 +34,7 @@ export class TripInviteLinkController { // The token grants trip membership, so reading it needs the same // share_manage permission as creating/rotating it — not just trip access. await this.requireManage(tripId, user); - const info = this.invites.get(tripId); + const info = await this.invites.get(tripId); return info ? info : { token: null }; } @@ -49,7 +49,7 @@ export class TripInviteLinkController { const days = body?.expires_in_days != null && String(body.expires_in_days).trim() !== '' ? Number.parseInt(String(body.expires_in_days)) : null; - const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null); + const info = await this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null); await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } }); return info; } @@ -57,7 +57,7 @@ export class TripInviteLinkController { @Delete() async remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { await this.requireManage(tripId, user); - this.invites.remove(tripId); + await this.invites.remove(tripId); await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) }); return { success: true }; } @@ -82,9 +82,9 @@ export class TripInviteController { } @Get(':token') - preview(@Param('token') token: string, @Req() req: Request) { + async preview(@Param('token') token: string, @Req() req: Request) { this.limit(req, 30); - const resolved = this.invites.resolve(token); + const resolved = await this.invites.resolve(token); if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404); return { trip_id: resolved.trip_id, title: resolved.title }; } @@ -93,7 +93,7 @@ export class TripInviteController { @HttpCode(200) async accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) { this.limit(req, 20); - const resolved = this.invites.resolve(token); + const resolved = await this.invites.resolve(token); if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404); const result = await this.invites.join(resolved.trip_id, user.id); await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } }); diff --git a/server/src/nest/trip-invite/trip-invite.mcp.ts b/server/src/nest/trip-invite/trip-invite.mcp.ts index 497f8bb31..64f4de6ab 100644 --- a/server/src/nest/trip-invite/trip-invite.mcp.ts +++ b/server/src/nest/trip-invite/trip-invite.mcp.ts @@ -47,7 +47,7 @@ export class TripInviteMcp { /** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */ private async denyManage(tripId: number, userId: number) { - if (!this.invites.verifyTripAccess(String(tripId), userId)) return noAccess(); + if (!(await this.invites.verifyTripAccess(String(tripId), userId))) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied(); return null; } @@ -69,7 +69,7 @@ export class TripInviteMcp { async getTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) { const denied = await this.denyManage(tripId, ctx.userId); if ((await denied)) return denied; - const info = this.invites.get(tripId); + const info = await this.invites.get(tripId); return ok({ invite_link: info ? this.describe(info) : null }); } @@ -98,7 +98,7 @@ export class TripInviteMcp { ? Number.parseInt(String(expires_in_days)) : null; const days = Number.isFinite(parsed as number) ? parsed : null; - const info = this.invites.createOrRotate(tripId, ctx.userId, days); + const info = await this.invites.createOrRotate(tripId, ctx.userId, days); // Minting a membership credential is audited wherever it happens, so an // admin reading the log sees the same row for a link made through an // assistant as for one made in the planner. No request here, hence no ip. @@ -125,7 +125,7 @@ export class TripInviteMcp { if (await this.isDemoUser(ctx.userId)) return demoDenied(); const denied = await this.denyManage(tripId, ctx.userId); if ((await denied)) return denied; - this.invites.remove(tripId); + await this.invites.remove(tripId); await this.audit.writeAudit({ userId: ctx.userId, action: 'trip.invite_link_delete', diff --git a/server/src/nest/trip-invite/trip-invite.service.ts b/server/src/nest/trip-invite/trip-invite.service.ts index ca7d816a3..40130671d 100644 --- a/server/src/nest/trip-invite/trip-invite.service.ts +++ b/server/src/nest/trip-invite/trip-invite.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import crypto from 'crypto'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import type { TripAccess } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; @@ -32,9 +33,10 @@ export class TripInviteService { private readonly dbs: DatabaseService, private readonly permissions: PermissionsService, private readonly membership: TripMembershipService, + private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string, userId: number) { + async verifyTripAccess(tripId: string, userId: number) { return this.dbs.canAccessTrip(tripId, userId); } @@ -43,7 +45,7 @@ export class TripInviteService { } /** The current invite link for a trip, or null if none exists. */ - get(tripId: string | number): TripInviteInfo | null { + async get(tripId: string | number): Promise { const row = this.dbs.get<{ token: string; expires_at: string | null; created_at: string }>( 'SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?', tripId, @@ -55,7 +57,7 @@ export class TripInviteService { * Create the trip's invite link, or rotate it to a fresh token (there is only * ever one row per trip). An optional expiry (days) can bound the link's life. */ - createOrRotate(tripId: string | number, createdBy: number, expiresInDays?: number | null): TripInviteInfo { + async createOrRotate(tripId: string | number, createdBy: number, expiresInDays?: number | null): Promise { const token = crypto.randomBytes(24).toString('base64url'); // Any non-positive/absent value (0, negatives, NaN, null, undefined) means // "no expiry" — deliberate: the UI sends null when no bound was chosen. @@ -67,7 +69,7 @@ export class TripInviteService { // Probe + write + re-select are one atomic unit so a concurrent rotation // can't interleave between them (the trip_id UNIQUE constraint would turn // that into a spurious 500). - return this.dbs.transaction(() => { + return await this.uow.transactional(async () => { const existing = this.dbs.get('SELECT id FROM trip_invite_tokens WHERE trip_id = ?', tripId); if (existing) { this.dbs.run( @@ -80,12 +82,12 @@ export class TripInviteService { tripId, token, createdBy, expiresAt, ); } - return this.get(tripId)!; + return (await this.get(tripId))!; }); } /** Remove the trip's invite link entirely (disable). */ - remove(tripId: string | number): void { + async remove(tripId: string | number): Promise { this.dbs.run('DELETE FROM trip_invite_tokens WHERE trip_id = ?', tripId); } @@ -95,7 +97,7 @@ export class TripInviteService { * trip title is safe to return for the join confirmation screen; an anonymous * caller never reaches this (the endpoint is JWT-guarded). */ - resolve(token: string): { trip_id: number; title: string } | null { + async resolve(token: string): Promise<{ trip_id: number; title: string } | null> { const row = this.dbs.get<{ trip_id: number; title: string; expires_at: string | null }>( `SELECT t.id AS trip_id, t.title AS title, ti.expires_at AS expires_at FROM trip_invite_tokens ti @@ -114,5 +116,5 @@ export class TripInviteService { /** Join the resolved trip as the current (authenticated, non-guest) user. * invited_by is null — they joined via a link, not a personal invite. */ - join(tripId: number, userId: number) { return this.membership.joinTripAsMember(tripId, userId, null); } + async join(tripId: number, userId: number) { return await this.membership.joinTripAsMember(tripId, userId, null); } } diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index 6378bbbbc..6a1651875 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -107,7 +107,7 @@ export class TripReadModelService { // Scope to the requesting member so other members' private packing items // (#858) never land in this viewer's offline cache. packingItems: this.packing.listItems(tripId, viewerId), - todoItems: this.todo.listItems(tripId), + todoItems: await this.todo.listItems(tripId), budgetItems: await this.budget.listBudgetItems(tripId), reservations: this.reservations.list(tripId), files: this.files.listFiles(tripId, false), diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index ae5bcf493..8d7608d3e 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -280,7 +280,7 @@ export class TripsMcp { const canReadCollab = collabEnabled && canRead(ctx.scopes, 'collab'); const canReadTodos = packingEnabled && canRead(ctx.scopes, 'todos'); const canReadRes = canRead(ctx.scopes, 'reservations'); - const todos = canReadTodos ? this.todos.listItems(tripId) : []; + const todos = canReadTodos ? await this.todos.listItems(tripId) : []; let pollCount = 0; let messageCount = 0; if (canReadCollab) { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 4fe736f37..05d16a94d 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -149,7 +149,7 @@ export async function createMcpTestRegistry(): Promise { ); const queryHelpersService = new QueryHelpersService(dbService); const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService, await createTestUnitOfWork(dbService.connection)); - const todoService = new TodoService(dbService, permissionsService, realtimeService); + const todoService = new TodoService(dbService, permissionsService, realtimeService, await createTestUnitOfWork(dbService.connection)); const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub()); const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService()); // Exactly one instance, shared by maps, places and share: its stampede guard @@ -235,7 +235,7 @@ export async function createMcpTestRegistry(): Promise { new TripPromptsMcp(tripsService, readModelService, packingService, addonsService), new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache), authService, guards), new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards), - new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)), + new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)), new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 278727644..68109634e 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -93,7 +93,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const budget = new BudgetService(dbs, permissions, exchangeRates, realtime, await createTestUnitOfWork(dbs.connection)); const addons = new AddonsService(dbs); const queryHelpers = new QueryHelpersService(dbs); - const todos = new TodoService(dbs, permissions, realtime); + const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection)); const packing = new PackingService(dbs, permissions, realtime, notificationsStub()); const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage); const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService()); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 3831bb059..4b673593f 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -48,7 +48,7 @@ const oauthSvc = new OauthService(oauthDbs, new AddonsService(oauthDbs), new Aud async function mintOauthToken(userId: number, audience: string | null, scopes: string[] = ['trips:read']): Promise<{ accessToken: string; clientId: string }> { const created = await oauthSvc.createOAuthClient(userId, 'MCP Test Client', ['https://client.example.com/cb'], scopes); const clientId = (created.client as { client_id: string }).client_id; - const tokens = oauthSvc.issueTokens(clientId, userId, scopes, null, audience); + const tokens = await oauthSvc.issueTokens(clientId, userId, scopes, null, audience); return { accessToken: tokens.access_token, clientId }; } @@ -465,8 +465,8 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { const { user } = createUser(testDb); const created = await oauthSvc.createOAuthClient(user.id, 'Scope Test Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); const clientId = (created.client as { client_id: string }).client_id; - const wide = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); - const narrow = oauthSvc.issueTokens(clientId, user.id, ['trips:read'], null, MCP_AUDIENCE); + const wide = await oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); + const narrow = await oauthSvc.issueTokens(clientId, user.id, ['trips:read'], null, MCP_AUDIENCE); const sessionId = await createSession(wide.access_token); // The tool surface was registered from the wide set; resuming with the @@ -485,8 +485,8 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { const { user } = createUser(testDb); const created = await oauthSvc.createOAuthClient(user.id, 'Scope Order Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); const clientId = (created.client as { client_id: string }).client_id; - const first = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); - const reordered = oauthSvc.issueTokens(clientId, user.id, ['trips:write', 'trips:read'], null, MCP_AUDIENCE); + const first = await oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); + const reordered = await oauthSvc.issueTokens(clientId, user.id, ['trips:write', 'trips:read'], null, MCP_AUDIENCE); const sessionId = await createSession(first.access_token); const res = await request(app) diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 922fdc068..8ede030fb 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -49,9 +49,11 @@ import { AuditService } from '../../src/nest/audit/audit.service'; // oauth.pending-codes.ts, so the routes under test see every code written here. const oauthDbs = new DatabaseService(testDb); const containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs)); -const createAuthCode = containerSideOauth.createAuthCode.bind(containerSideOauth); -const createOAuthClient = containerSideOauth.createOAuthClient.bind(containerSideOauth); -const getUserByAccessToken = containerSideOauth.getUserByAccessToken.bind(containerSideOauth); +// Arrow wrappers rather than `.bind`: with `strictBindCallApply: false` a bound +// alias is typed `any`, which hides a missing `await` on the now-async methods. +const createAuthCode = (...args: Parameters) => containerSideOauth.createAuthCode(...args); +const createOAuthClient = (...args: Parameters) => containerSideOauth.createOAuthClient(...args); +const getUserByAccessToken = (...args: Parameters) => containerSideOauth.getUserByAccessToken(...args); let nestApp: INestApplication; let app: Application; @@ -1584,7 +1586,7 @@ describe('POST /oauth/token — client_credentials grant', () => { }); expect(res.status).toBe(200); - const info = getUserByAccessToken(res.body.access_token); + const info = await getUserByAccessToken(res.body.access_token); expect(info).not.toBeNull(); expect(info!.user.id).toBe(user.id); expect(info!.scopes).toEqual(['trips:read']); diff --git a/server/tests/integration/storage-restore-reload.test.ts b/server/tests/integration/storage-restore-reload.test.ts index 7ddcd12df..6fc28aac5 100644 --- a/server/tests/integration/storage-restore-reload.test.ts +++ b/server/tests/integration/storage-restore-reload.test.ts @@ -35,6 +35,7 @@ import type { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.se import { StorageEventsService } from '../../src/nest/storage/storage-events.service'; import { BACKENDS_KEY, CATEGORIES_KEY, StorageRegistryService } from '../../src/nest/storage/storage-registry.service'; import { StorageService } from '../../src/nest/storage/storage.service'; +import { createTestUnitOfWork } from '../helpers/test-uow'; const testDb = new Database(':memory:'); testDb.exec('PRAGMA journal_mode = WAL'); @@ -80,8 +81,8 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => { ]); setSetting(CATEGORIES_KEY, { files: 'nas-a' }); - const registry = new StorageRegistryService(db, envStub(), new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, envStub(), new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); expect(registry.resolve('files').backendName).toBe('nas-a'); @@ -105,7 +106,7 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => { // The fix under test: StorageService.reloadConfig(), the narrow passthrough // restoreFromZip now calls right after reinitialize() and right before // rehydration (see backup.impl.ts). - storage.reloadConfig(); + await storage.reloadConfig(); expect(registry.resolve('files').backendName).toBe('nas-b'); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 9b32ca8d7..de34a4418 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -562,7 +562,7 @@ describe('AddonsService place shadow flag', () => { expect(await svc().getPlaceShadow()).toEqual({ enabled: expected }); expect(dbMock._stmt.get).toHaveBeenLastCalledWith('place_shadow_enabled'); - expect(shadow.enabled()).toBe(expected); + expect(await shadow.enabled()).toBe(expected); expect(dbMock.prepare).toHaveBeenLastCalledWith( "SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'", ); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index 7a103daf6..e11cc84d6 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -191,7 +191,7 @@ describe('AdminController read-only getters', () => { expect(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(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); - expect(adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ 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/backup.controller.test.ts b/server/tests/unit/nest/backup.controller.test.ts index 7231f0199..6abbef919 100644 --- a/server/tests/unit/nest/backup.controller.test.ts +++ b/server/tests/unit/nest/backup.controller.test.ts @@ -172,18 +172,18 @@ describe('BackupController', () => { expect(thrown(() => bc(svc(), job({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) })).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } }); }); - it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => { + it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); process.env.NODE_ENV = 'development'; - const r = thrown(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) })).updateAutoSettings(user, {}, req)); + const r = await thrownAsync(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) })).updateAutoSettings(user, {}, req)); expect(r.status).toBe(500); expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' }); }); - it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => { + it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); process.env.NODE_ENV = 'production'; - const r = thrown(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) })).updateAutoSettings(user, {}, req)); + const r = await thrownAsync(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) })).updateAutoSettings(user, {}, req)); expect(r.status).toBe(500); expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined }); }); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index 216f0b127..b419374d1 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -135,8 +135,8 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', async () => { - expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } }); - expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } }); + expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).update(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } }); const updateFile = vi.fn().mockReturnValue({ id: 9 }); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial); expect(await fc(s).update(user, trip, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } }); @@ -147,7 +147,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route getFileById: vi.fn().mockReturnValue({ id: 9 }), findForeignLinkTarget: vi.fn().mockReturnValue('budget_item_id'), }); - expect(thrown(() => fc(rejectSvc).update(user, trip, '5', '9', { budget_item_id: '99' }))).toEqual({ + expect(await rejected(fc(rejectSvc).update(user, trip, '5', '9', { budget_item_id: '99' }))).toEqual({ status: 400, body: { error: 'Linked item does not belong to this trip' }, }); @@ -168,8 +168,8 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('PATCH /:id/star 403/404, else toggles', async () => { - expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); - expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 }); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial); expect(await fc(s).star(user, trip, '5', '9')).toEqual({ file: { id: 9, starred: 1 } }); @@ -177,8 +177,8 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('DELETE /:id soft-delete 403/404, else success', async () => { - expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } }); - expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } }); + expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); const softDeleteFile = vi.fn(); const broadcast = vi.fn(); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial); @@ -187,7 +187,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('POST /:id/restore 404 not in trash, else restores', async () => { - expect(thrown(() => fc(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial)).restore(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } }); + expect(await rejected(fc(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial)).restore(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } }); const restoreFile = vi.fn().mockReturnValue({ id: 9 }); const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial); expect(await fc(s).restore(user, trip, '5', '9')).toEqual({ file: { id: 9 } }); @@ -207,7 +207,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('POST /:id/link 404 unknown file, else links', async () => { - expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).link(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial)).link(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } }); const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial); expect(await fc(s).link(user, trip, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] }); @@ -221,12 +221,13 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route expect(fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] }); }); - it('the link routes resolve the file against :tripId, so a foreign file is 404', () => { + it('the link routes resolve the file against :tripId, so a foreign file is 404', async () => { const foreign = () => fsvc({ getFileById: vi.fn().mockReturnValue(undefined), deleteFileLink: vi.fn(), getFileLinks: vi.fn() } as Partial); const unlinkSvc = foreign(); - expect(thrown(() => fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(await rejected(fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } }); expect(unlinkSvc.deleteFileLink).not.toHaveBeenCalled(); const listSvc = foreign(); + // `links` is still a synchronous handler, so it throws rather than rejecting. expect(thrown(() => fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); expect(listSvc.getFileLinks).not.toHaveBeenCalled(); }); @@ -234,9 +235,9 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route it('the trash + link routes all reject without file_delete / file_edit', async () => { const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) }); await expect(fc(denied()).permanent(user, trip, '5', '9')).rejects.toMatchObject({ status: 403 }); - expect(thrown(() => fc(denied()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); - expect(thrown(() => fc(denied()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } }); - expect(thrown(() => fc(denied()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await rejected(fc(denied()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await rejected(fc(denied()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await rejected(fc(denied()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } }); }); }); diff --git a/server/tests/unit/nest/mcp-shared.service.test.ts b/server/tests/unit/nest/mcp-shared.service.test.ts index 51c490ae9..d2ea419c3 100644 --- a/server/tests/unit/nest/mcp-shared.service.test.ts +++ b/server/tests/unit/nest/mcp-shared.service.test.ts @@ -81,12 +81,12 @@ describe('hasTripPermission', () => { }); describe('isAdminUser', () => { - it('GRD-010: reflects the users.role column and is false for unknown ids', () => { + it('GRD-010: reflects the users.role column and is false for unknown ids', async () => { const { user } = createUser(testDb); - expect(svc.isAdminUser(user.id)).toBe(false); + expect(await svc.isAdminUser(user.id)).toBe(false); testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(user.id); - expect(svc.isAdminUser(user.id)).toBe(true); - expect(svc.isAdminUser(424242)).toBe(false); + expect(await svc.isAdminUser(user.id)).toBe(true); + expect(await svc.isAdminUser(424242)).toBe(false); }); }); diff --git a/server/tests/unit/nest/mcp-transport.service.test.ts b/server/tests/unit/nest/mcp-transport.service.test.ts index 4755c8e6c..09bfb3333 100644 --- a/server/tests/unit/nest/mcp-transport.service.test.ts +++ b/server/tests/unit/nest/mcp-transport.service.test.ts @@ -155,43 +155,43 @@ describe('armSseKeepalive', () => { }); describe('McpTransportService.verifyToken', () => { - it('MCPT-010: rejects a missing header, a schemeless token, and non-Bearer schemes', () => { + it('MCPT-010: rejects a missing header, a schemeless token, and non-Bearer schemes', async () => { const { svc } = makeService(); - expect(svc.verifyToken(undefined)).toBeNull(); - expect(svc.verifyToken('token-without-scheme')).toBeNull(); - expect(svc.verifyToken('Basic dXNlcjpwdw==')).toBeNull(); - expect(svc.verifyToken('Bearer ')).toBeNull(); + expect(await svc.verifyToken(undefined)).toBeNull(); + expect(await svc.verifyToken('token-without-scheme')).toBeNull(); + expect(await svc.verifyToken('Basic dXNlcjpwdw==')).toBeNull(); + expect(await svc.verifyToken('Bearer ')).toBeNull(); }); - it('MCPT-011: trekoa_ tokens resolve through OauthService and enforce the RFC 8707 audience', () => { + it('MCPT-011: trekoa_ tokens resolve through OauthService and enforce the RFC 8707 audience', async () => { const info = { user, scopes: ['trips:read'], clientId: 'cid-1', audience: 'https://trek.example.test/mcp' }; - const { svc, oauth } = makeService({ oauth: { getUserByAccessToken: vi.fn(() => info) } }); - expect(svc.verifyToken('Bearer trekoa_x')).toEqual({ + const { svc, oauth } = makeService({ oauth: { getUserByAccessToken: vi.fn(async () => info) } }); + expect(await svc.verifyToken('Bearer trekoa_x')).toEqual({ user, scopes: ['trips:read'], clientId: 'cid-1', isStaticToken: false, }); expect(oauth.getUserByAccessToken).toHaveBeenCalledWith('trekoa_x'); const wrongAudience = makeService({ - oauth: { getUserByAccessToken: vi.fn(() => ({ ...info, audience: 'https://other.example.com' })) }, + oauth: { getUserByAccessToken: vi.fn(async () => ({ ...info, audience: 'https://other.example.com' })) }, }); - expect(wrongAudience.svc.verifyToken('Bearer trekoa_x')).toBeNull(); + expect(await wrongAudience.svc.verifyToken('Bearer trekoa_x')).toBeNull(); const unknown = makeService(); - expect(unknown.svc.verifyToken('Bearer trekoa_dead')).toBeNull(); + expect(await unknown.svc.verifyToken('Bearer trekoa_dead')).toBeNull(); }); - it('MCPT-012: trek_ static tokens resolve through TokenService with full access + notice flag', () => { + it('MCPT-012: trek_ static tokens resolve through TokenService with full access + notice flag', async () => { const { svc, tokens } = makeService({ tokens: { verifyMcpToken: vi.fn(() => user) } }); - expect(svc.verifyToken('Bearer trek_x')).toEqual({ user, scopes: null, clientId: null, isStaticToken: true }); + expect(await svc.verifyToken('Bearer trek_x')).toEqual({ user, scopes: null, clientId: null, isStaticToken: true }); expect(tokens.verifyMcpToken).toHaveBeenCalledWith('trek_x'); - expect(makeService().svc.verifyToken('Bearer trek_dead')).toBeNull(); + expect(await makeService().svc.verifyToken('Bearer trek_dead')).toBeNull(); }); - it('MCPT-013: everything else falls back to the JWT path', () => { + it('MCPT-013: everything else falls back to the JWT path', async () => { const { svc, auth } = makeService({ auth: { verifyJwtToken: vi.fn(() => user) } }); - expect(svc.verifyToken('Bearer some.jwt.token')).toEqual({ user, scopes: null, clientId: null, isStaticToken: false }); + expect(await svc.verifyToken('Bearer some.jwt.token')).toEqual({ user, scopes: null, clientId: null, isStaticToken: false }); expect(auth.verifyJwtToken).toHaveBeenCalledWith('some.jwt.token'); - expect(makeService().svc.verifyToken('Bearer not-a-jwt')).toBeNull(); + expect(await makeService().svc.verifyToken('Bearer not-a-jwt')).toBeNull(); }); }); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 8de069007..e79da2519 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -72,26 +72,28 @@ const dbs = new DatabaseService(testDb); const addonsStub = { isAddonEnabled } as unknown as AddonsService; const svc = new OauthService(dbs, addonsStub, new AuditService(dbs)); -// Legacy free-function names bound to the service, so the moved cases below read -// exactly as they did before the fold. -const createOAuthClient = svc.createOAuthClient.bind(svc); -const listOAuthClients = svc.listOAuthClients.bind(svc); -const deleteOAuthClient = svc.deleteOAuthClient.bind(svc); -const rotateOAuthClientSecret = svc.rotateOAuthClientSecret.bind(svc); -const createAuthCode = svc.createAuthCode.bind(svc); -const consumeAuthCode = svc.consumeAuthCode.bind(svc); -const issueTokens = svc.issueTokens.bind(svc); -const getUserByAccessToken = svc.getUserByAccessToken.bind(svc); -const refreshTokens = svc.refreshTokens.bind(svc); -const revokeToken = svc.revokeToken.bind(svc); -const listOAuthSessions = svc.listOAuthSessions.bind(svc); -const revokeSession = svc.revokeSession.bind(svc); -const validateAuthorizeRequest = svc.validateAuthorizeRequest.bind(svc); -const verifyPKCE = svc.verifyPKCE.bind(svc); -const authenticateClient = svc.authenticateClient.bind(svc); -const saveConsent = svc.saveConsent.bind(svc); -const getConsent = svc.getConsent.bind(svc); -const isConsentSufficient = svc.isConsentSufficient.bind(svc); +// Legacy free-function names delegating to the service, so the moved cases below +// read exactly as they did before the fold. Arrow wrappers rather than `.bind`: +// with `strictBindCallApply: false` a bound alias is typed `any`, which would +// hide every missing `await` on the now-async methods from the type checker. +const createOAuthClient = (...args: Parameters) => svc.createOAuthClient(...args); +const listOAuthClients = (...args: Parameters) => svc.listOAuthClients(...args); +const deleteOAuthClient = (...args: Parameters) => svc.deleteOAuthClient(...args); +const rotateOAuthClientSecret = (...args: Parameters) => svc.rotateOAuthClientSecret(...args); +const createAuthCode = (...args: Parameters) => svc.createAuthCode(...args); +const consumeAuthCode = (...args: Parameters) => svc.consumeAuthCode(...args); +const issueTokens = (...args: Parameters) => svc.issueTokens(...args); +const getUserByAccessToken = (...args: Parameters) => svc.getUserByAccessToken(...args); +const refreshTokens = (...args: Parameters) => svc.refreshTokens(...args); +const revokeToken = (...args: Parameters) => svc.revokeToken(...args); +const listOAuthSessions = (...args: Parameters) => svc.listOAuthSessions(...args); +const revokeSession = (...args: Parameters) => svc.revokeSession(...args); +const validateAuthorizeRequest = (...args: Parameters) => svc.validateAuthorizeRequest(...args); +const verifyPKCE = (...args: Parameters) => svc.verifyPKCE(...args); +const authenticateClient = (...args: Parameters) => svc.authenticateClient(...args); +const saveConsent = (...args: Parameters) => svc.saveConsent(...args); +const getConsent = (...args: Parameters) => svc.getConsent(...args); +const isConsentSufficient = (...args: Parameters) => svc.isConsentSufficient(...args); beforeAll(() => { createTables(testDb); @@ -273,15 +275,15 @@ describe('createOAuthClient', () => { // --------------------------------------------------------------------------- describe('listOAuthClients', () => { - it('returns empty array for user with no clients', () => { + it('returns empty array for user with no clients', async () => { const { user } = createUser(testDb); - expect(listOAuthClients(user.id)).toEqual([]); + expect(await listOAuthClients(user.id)).toEqual([]); }); it('returns created clients with redirect_uris and allowed_scopes as arrays', async () => { const { user } = createUser(testDb); await makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] }); - const clients = listOAuthClients(user.id); + const clients = await listOAuthClients(user.id); expect(clients).toHaveLength(1); expect(clients[0].name).toBe('Client A'); expect(Array.isArray(clients[0].redirect_uris)).toBe(true); @@ -301,7 +303,7 @@ describe('deleteOAuthClient', () => { const clientRowId = created.client!.id as string; const result = await deleteOAuthClient(user.id, clientRowId); expect(result.success).toBe(true); - expect(listOAuthClients(user.id)).toHaveLength(0); + expect(await listOAuthClients(user.id)).toHaveLength(0); }); it('returns 404 for non-existent client', async () => { @@ -345,12 +347,12 @@ describe('rotateOAuthClientSecret', () => { const { user } = createUser(testDb); const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { access_token } = issueTokens(clientId, user.id, ['trips:read']); - expect(getUserByAccessToken(access_token)).not.toBeNull(); + const { access_token } = await issueTokens(clientId, user.id, ['trips:read']); + expect(await getUserByAccessToken(access_token)).not.toBeNull(); await rotateOAuthClientSecret(user.id, created.client!.id as string); - expect(getUserByAccessToken(access_token)).toBeNull(); + expect(await getUserByAccessToken(access_token)).toBeNull(); }); }); @@ -369,6 +371,7 @@ describe('createAuthCode + consumeAuthCode', () => { userId: user.id, redirectUri: 'https://example.com/callback', scopes: ['trips:read'], + resource: null, codeChallenge: 'abc123', codeChallengeMethod: 'S256', }); @@ -379,7 +382,7 @@ describe('createAuthCode + consumeAuthCode', () => { expect(entry!.clientId).toBe(clientId); }); - it('returns null for non-existent code', () => { + it('returns null for non-existent code', async () => { expect(consumeAuthCode('does-not-exist')).toBeNull(); }); @@ -393,6 +396,7 @@ describe('createAuthCode + consumeAuthCode', () => { userId: user.id, redirectUri: 'https://example.com/callback', scopes: ['trips:read'], + resource: null, codeChallenge: 'abc123', codeChallengeMethod: 'S256', }); @@ -412,7 +416,7 @@ describe('issueTokens + getUserByAccessToken', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const tokens = issueTokens(clientId, user.id, ['trips:read']); + const tokens = await issueTokens(clientId, user.id, ['trips:read']); expect(tokens.access_token.startsWith('trekoa_')).toBe(true); expect(tokens.refresh_token.startsWith('trekrf_')).toBe(true); expect(tokens.token_type).toBe('Bearer'); @@ -424,16 +428,16 @@ describe('issueTokens + getUserByAccessToken', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { access_token } = issueTokens(clientId, user.id, ['trips:read', 'budget:write']); - const info = getUserByAccessToken(access_token); + const { access_token } = await issueTokens(clientId, user.id, ['trips:read', 'budget:write']); + const info = await getUserByAccessToken(access_token); expect(info).not.toBeNull(); expect(info!.user.email).toBe(user.email); expect(info!.scopes).toContain('trips:read'); expect(info!.scopes).toContain('budget:write'); }); - it('getUserByAccessToken returns null for unknown token', () => { - expect(getUserByAccessToken('trekoa_unknown')).toBeNull(); + it('getUserByAccessToken returns null for unknown token', async () => { + expect(await getUserByAccessToken('trekoa_unknown')).toBeNull(); }); it('getUserByAccessToken returns null for revoked token', async () => { @@ -441,9 +445,9 @@ describe('issueTokens + getUserByAccessToken', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { access_token } = issueTokens(clientId, user.id, ['trips:read']); + const { access_token } = await issueTokens(clientId, user.id, ['trips:read']); await revokeToken(access_token, clientId); - expect(getUserByAccessToken(access_token)).toBeNull(); + expect(await getUserByAccessToken(access_token)).toBeNull(); }); }); @@ -458,7 +462,7 @@ describe('refreshTokens', () => { const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']); const result = await refreshTokens(refresh_token, clientId, rawSecret); expect(result.error).toBeUndefined(); expect(result.tokens).toBeDefined(); @@ -471,9 +475,9 @@ describe('refreshTokens', () => { const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']); + const { access_token, refresh_token } = await issueTokens(clientId, user.id, ['trips:read']); await refreshTokens(refresh_token, clientId, rawSecret); - expect(getUserByAccessToken(access_token)).toBeNull(); + expect(await getUserByAccessToken(access_token)).toBeNull(); }); it('does not revoke the active MCP session on a normal (non-replayed) refresh (#1475)', async () => { @@ -482,7 +486,7 @@ describe('refreshTokens', () => { const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; const result = await refreshTokens(refresh_token, clientId, rawSecret); expect(result.error).toBeUndefined(); @@ -506,7 +510,7 @@ describe('refreshTokens', () => { const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']); + const { access_token, refresh_token } = await issueTokens(clientId, user.id, ['trips:read']); await revokeToken(access_token, clientId); const result = await refreshTokens(refresh_token, clientId, rawSecret); expect(result.error).toBe('invalid_grant'); @@ -517,7 +521,7 @@ describe('refreshTokens', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']); const result = await refreshTokens(refresh_token, clientId, 'wrong-secret'); expect(result.error).toBe('invalid_client'); expect(result.status).toBe(401); @@ -534,11 +538,11 @@ describe('revokeToken', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { access_token } = issueTokens(clientId, user.id, ['trips:read']); - expect(getUserByAccessToken(access_token)).not.toBeNull(); + const { access_token } = await issueTokens(clientId, user.id, ['trips:read']); + expect(await getUserByAccessToken(access_token)).not.toBeNull(); await revokeToken(access_token, clientId); - expect(getUserByAccessToken(access_token)).toBeNull(); + expect(await getUserByAccessToken(access_token)).toBeNull(); }); }); @@ -552,8 +556,8 @@ describe('listOAuthSessions + revokeSession', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - issueTokens(clientId, user.id, ['trips:read']); - const sessions = listOAuthSessions(user.id); + await issueTokens(clientId, user.id, ['trips:read']); + const sessions = await listOAuthSessions(user.id); expect(sessions).toHaveLength(1); expect(sessions[0].client_id).toBe(clientId); }); @@ -563,9 +567,9 @@ describe('listOAuthSessions + revokeSession', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { access_token } = issueTokens(clientId, user.id, ['trips:read']); + const { access_token } = await issueTokens(clientId, user.id, ['trips:read']); await revokeToken(access_token, clientId); - const sessions = listOAuthSessions(user.id); + const sessions = await listOAuthSessions(user.id); expect(sessions).toHaveLength(0); }); @@ -580,13 +584,13 @@ describe('listOAuthSessions + revokeSession', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - issueTokens(clientId, user.id, ['trips:read']); - const sessions = listOAuthSessions(user.id); + await issueTokens(clientId, user.id, ['trips:read']); + const sessions = await listOAuthSessions(user.id); const sessionId = sessions[0].id as number; const result = await revokeSession(user.id, sessionId); expect(result.success).toBe(true); - expect(listOAuthSessions(user.id)).toHaveLength(0); + expect(await listOAuthSessions(user.id)).toHaveLength(0); }); }); @@ -733,13 +737,13 @@ describe('validateAuthorizeRequest', () => { // --------------------------------------------------------------------------- describe('verifyPKCE', () => { - it('returns true for valid code_verifier / code_challenge pair (SHA256 base64url)', () => { + it('returns true for valid code_verifier / code_challenge pair (SHA256 base64url)', async () => { const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); expect(verifyPKCE(verifier, challenge)).toBe(true); }); - it('returns false for wrong verifier', () => { + it('returns false for wrong verifier', async () => { const verifier = 'correct-verifier'; const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); expect(verifyPKCE('wrong-verifier', challenge)).toBe(false); @@ -757,7 +761,7 @@ describe('authenticateClient', () => { const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const client = authenticateClient(clientId, rawSecret); + const client = await authenticateClient(clientId, rawSecret); expect(client).not.toBeNull(); expect(client!.client_id).toBe(clientId); }); @@ -767,11 +771,11 @@ describe('authenticateClient', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - expect(authenticateClient(clientId, 'wrong-secret')).toBeNull(); + expect(await authenticateClient(clientId, 'wrong-secret')).toBeNull(); }); - it('returns null for unknown client_id', () => { - expect(authenticateClient('unknown-client-id', 'any-secret')).toBeNull(); + it('returns null for unknown client_id', async () => { + expect(await authenticateClient('unknown-client-id', 'any-secret')).toBeNull(); }); }); @@ -786,18 +790,18 @@ describe('saveConsent + getConsent + isConsentSufficient', () => { const clientId = created.client!.client_id as string; await saveConsent(clientId, user.id, ['trips:read', 'budget:write']); - const consent = getConsent(clientId, user.id); + const consent = await getConsent(clientId, user.id); expect(consent).not.toBeNull(); expect(consent).toContain('trips:read'); expect(consent).toContain('budget:write'); }); - it('isConsentSufficient returns true when all requested scopes are in existing', () => { + it('isConsentSufficient returns true when all requested scopes are in existing', async () => { expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read'])).toBe(true); expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read', 'budget:write'])).toBe(true); }); - it('isConsentSufficient returns false when some scopes are missing', () => { + it('isConsentSufficient returns false when some scopes are missing', async () => { expect(isConsentSufficient(['trips:read'], ['trips:read', 'budget:write'])).toBe(false); expect(isConsentSufficient([], ['trips:read'])).toBe(false); }); @@ -816,7 +820,7 @@ describe('saveConsent — scope union (M5)', () => { await saveConsent(clientId, user.id, ['trips:read']); await saveConsent(clientId, user.id, ['budget:write']); - const consent = getConsent(clientId, user.id); + const consent = await getConsent(clientId, user.id); expect(consent).toContain('trips:read'); expect(consent).toContain('budget:write'); }); @@ -830,7 +834,7 @@ describe('saveConsent — scope union (M5)', () => { // approve only trips:read on a later request await saveConsent(clientId, user.id, ['trips:read']); - const consent = getConsent(clientId, user.id); + const consent = await getConsent(clientId, user.id); // trips:write should NOT be removed (union semantics) expect(consent).toContain('trips:read'); expect(consent).toContain('trips:write'); @@ -845,9 +849,9 @@ describe('saveConsent — scope union (M5)', () => { await saveConsent(clientId, user.id, ['budget:write']); // Should not require consent again for either scope - expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read'])).toBe(true); - expect(isConsentSufficient(getConsent(clientId, user.id)!, ['budget:write'])).toBe(true); - expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read', 'budget:write'])).toBe(true); + expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['trips:read'])).toBe(true); + expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['budget:write'])).toBe(true); + expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['trips:read', 'budget:write'])).toBe(true); }); }); @@ -861,8 +865,8 @@ describe('getUserByAccessToken — includes clientId (C2)', () => { const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const { access_token } = issueTokens(clientId, user.id, ['trips:read']); - const info = getUserByAccessToken(access_token); + const { access_token } = await issueTokens(clientId, user.id, ['trips:read']); + const info = await getUserByAccessToken(access_token); expect(info).not.toBeNull(); expect(info!.clientId).toBe(clientId); }); @@ -891,7 +895,7 @@ describe('refreshTokens — replay detection (C3)', () => { const rawSecret = created.client!.client_secret as string; // Issue tokens, then rotate once (old token becomes revoked) - const { refresh_token: firstRefresh } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: firstRefresh } = await issueTokens(clientId, user.id, ['trips:read']); const rotateResult = await refreshTokens(firstRefresh, clientId, rawSecret); expect(rotateResult.error).toBeUndefined(); const { refresh_token: secondRefresh } = rotateResult.tokens!; @@ -915,7 +919,7 @@ describe('refreshTokens — replay detection (C3)', () => { const rawSecret = created.client!.client_secret as string; // Issue → rotate once - const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']); const r1 = await refreshTokens(first, clientId, rawSecret); const { access_token: access2, refresh_token: second } = r1.tokens!; @@ -924,7 +928,7 @@ describe('refreshTokens — replay detection (C3)', () => { await refreshTokens(first, clientId, rawSecret); // The rotated access token should also be dead now - expect(getUserByAccessToken(access2)).toBeNull(); + expect(await getUserByAccessToken(access2)).toBeNull(); // The second refresh token should also be revoked const r2 = await refreshTokens(second, clientId, rawSecret); @@ -937,7 +941,7 @@ describe('refreshTokens — replay detection (C3)', () => { const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']); // Rotate once const r1 = await refreshTokens(first, clientId, rawSecret); const { refresh_token: second } = r1.tokens!; @@ -973,7 +977,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { it('OAUTH-GRACE-001: two clients refreshing the same token both get tokens', async () => { const { user, clientId, rawSecret } = await setup(); - const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']); const first = await refreshTokens(shared, clientId, rawSecret); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; @@ -984,13 +988,13 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { expect(second.tokens!.refresh_token).not.toBe(first.tokens!.refresh_token); // The whole point: no chain revocation, no torn-down MCP sessions, no login window. expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore); - expect(getUserByAccessToken(first.tokens!.access_token)).not.toBeNull(); - expect(getUserByAccessToken(second.tokens!.access_token)).not.toBeNull(); + expect(await getUserByAccessToken(first.tokens!.access_token)).not.toBeNull(); + expect(await getUserByAccessToken(second.tokens!.access_token)).not.toBeNull(); }); it('OAUTH-GRACE-002: both successors keep working afterwards', async () => { const { user, clientId, rawSecret } = await setup(); - const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']); const a = (await refreshTokens(shared, clientId, rawSecret)).tokens!; const b = (await refreshTokens(shared, clientId, rawSecret)).tokens!; @@ -1000,7 +1004,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { it('OAUTH-GRACE-003: the same token replayed after the window is still theft', async () => { const { user, clientId, rawSecret } = await setup(); - const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']); const rotated = (await refreshTokens(shared, clientId, rawSecret)).tokens!; agePastGrace(shared); @@ -1012,7 +1016,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { it('OAUTH-GRACE-004: a token revoked by logout is not re-opened by the window', async () => { const { user, clientId, rawSecret } = await setup(); - const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']); // Explicit revocation leaves no successor, so there is nothing to be // concurrent with — the grace must not resurrect it. @@ -1024,7 +1028,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { it('OAUTH-GRACE-005: a chain killed by a real replay stays dead inside the window', async () => { const { user, clientId, rawSecret } = await setup(); - const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); + const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']); const second = (await refreshTokens(first, clientId, rawSecret)).tokens!; // Real theft: the first token turns up again once the window has passed. @@ -1042,24 +1046,24 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { // --------------------------------------------------------------------------- describe('verifyPKCE — format validation (H1)', () => { - it('returns false for a code_verifier that is too short (< 43 chars)', () => { + it('returns false for a code_verifier that is too short (< 43 chars)', async () => { const { challenge } = makePkce(); expect(verifyPKCE('short', challenge)).toBe(false); }); - it('returns false for a code_verifier that is too long (> 128 chars)', () => { + it('returns false for a code_verifier that is too long (> 128 chars)', async () => { const { challenge } = makePkce(); const longVerifier = 'a'.repeat(129); expect(verifyPKCE(longVerifier, challenge)).toBe(false); }); - it('returns false for a code_verifier with invalid characters', () => { + it('returns false for a code_verifier with invalid characters', async () => { const { challenge } = makePkce(); const badVerifier = 'A'.repeat(42) + ' '; // space is not allowed expect(verifyPKCE(badVerifier, challenge)).toBe(false); }); - it('returns true for a valid 43-char verifier matching its challenge', () => { + it('returns true for a valid 43-char verifier matching its challenge', async () => { const { verifier, challenge } = makePkce(); expect(verifyPKCE(verifier, challenge)).toBe(true); }); @@ -1145,7 +1149,7 @@ describe('addon gate and MCP endpoint', () => { expect(await svc.mcpEnabled()).toBe(false); }); - it('mcpSafeUrl forwards to the app-config helper', () => { + it('mcpSafeUrl forwards to the app-config helper', async () => { expect(svc.mcpSafeUrl()).toBe(getMcpSafeUrl()); }); }); @@ -1161,7 +1165,7 @@ describe('pending-code store', () => { codeChallengeMethod: 'S256' as const, }; - it('refuses a new code at capacity and the sweep frees it again', () => { + it('refuses a new code at capacity and the sweep frees it again', async () => { for (let i = 0; i < MAX_PENDING_CODES; i++) createAuthCode(codeParams); expect(createAuthCode(codeParams)).toBeNull(); @@ -1174,7 +1178,7 @@ describe('pending-code store', () => { sweepPendingCodes(Date.now() + 3 * 60 * 1000); }); - it('a code past its TTL is consumed as invalid', () => { + it('a code past its TTL is consumed as invalid', async () => { const code = createAuthCode(codeParams)!; const realNow = Date.now; Date.now = () => realNow() + 3 * 60 * 1000; @@ -1191,10 +1195,10 @@ describe('branches the legacy suite could not reach', () => { const { user } = createUser(testDb); const created = await makeClient(user.id); const clientId = (created.client as { client_id: string }).client_id; - const tokens = issueTokens(clientId, user.id, ['trips:read']); + const tokens = await issueTokens(clientId, user.id, ['trips:read']); testDb.prepare("UPDATE oauth_tokens SET access_token_expires_at = '2000-01-01T00:00:00.000Z'").run(); - expect(getUserByAccessToken(tokens.access_token)).toBeNull(); + expect(await getUserByAccessToken(tokens.access_token)).toBeNull(); }); it('refreshTokens rejects an unknown client before touching the token', async () => { @@ -1205,7 +1209,7 @@ describe('branches the legacy suite could not reach', () => { const { user } = createUser(testDb); const created = await createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true }); const clientId = (created.client as { client_id: string }).client_id; - const tokens = issueTokens(clientId, user.id, ['trips:read']); + const tokens = await issueTokens(clientId, user.id, ['trips:read']); const result = await refreshTokens(tokens.refresh_token, clientId, undefined); @@ -1220,8 +1224,8 @@ describe('branches the legacy suite could not reach', () => { const conf = await makeClient(user.id, { name: 'Confidential' }); const confId = (conf.client as { client_id: string }).client_id; - expect(authenticateClient(pubId, undefined)?.client_id).toBe(pubId); - expect(authenticateClient(confId, undefined)).toBeNull(); + expect((await authenticateClient(pubId, undefined))?.client_id).toBe(pubId); + expect(await authenticateClient(confId, undefined)).toBeNull(); }); it('validateAuthorizeRequest rejects a resource that is not the MCP endpoint', async () => { @@ -1266,7 +1270,7 @@ describe('branches the legacy suite could not reach', () => { }); describe('module-scoped OAuth state', () => { - it('shares the pending-code map across service instances', () => { + it('shares the pending-code map across service instances', async () => { // The load-bearing invariant: the consent controller writes the code through // the DI singleton, the SDK exchange path reads it back. The map is module- // scoped, so even a second hand-built instance must see it — two maps would @@ -1311,7 +1315,7 @@ describe('OauthModule', () => { // --------------------------------------------------------------------------- describe('admin OAuth sessions', () => { - it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', () => { + it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO oauth_clients (client_id, client_secret_hash, name) VALUES ('c1', 'hash', 'Client')").run(); testDb.prepare(` @@ -1320,7 +1324,7 @@ describe('admin OAuth sessions', () => { VALUES ('c1', ?, 'ahash', 'rhash', 'not-json{', datetime('now', '+1 hour'), datetime('now', '+1 day')) `).run(user.id); - const sessions = svc.listAllOAuthSessions() as any[]; + const sessions = (await svc.listAllOAuthSessions()) as any[]; expect(sessions).toHaveLength(1); expect(sessions[0].scopes).toBeNull(); }); diff --git a/server/tests/unit/nest/place-shadow.controller.test.ts b/server/tests/unit/nest/place-shadow.controller.test.ts index 978866167..5dd613cbf 100644 --- a/server/tests/unit/nest/place-shadow.controller.test.ts +++ b/server/tests/unit/nest/place-shadow.controller.test.ts @@ -19,46 +19,46 @@ const BODY = { describe('PlaceShadowController', () => { describe('POST /api/place-shadow/pick', () => { - it('answers 200 { recorded: false } when the log is off, never an error', () => { - const record = vi.fn().mockReturnValue(false); - expect(makeController({ record }).pick(BODY)).toEqual({ recorded: false }); + it('answers 200 { recorded: false } when the log is off, never an error', async () => { + const record = vi.fn().mockResolvedValue(false); + expect(await makeController({ record }).pick(BODY)).toEqual({ recorded: false }); expect(record).toHaveBeenCalledWith(BODY); }); - it('reports a written row', () => { - expect(makeController({ record: vi.fn().mockReturnValue(true) }).pick(BODY)).toEqual({ recorded: true }); + it('reports a written row', async () => { + expect(await makeController({ record: vi.fn().mockResolvedValue(true) }).pick(BODY)).toEqual({ recorded: true }); }); }); describe('GET /api/place-shadow/export', () => { const page = { version: 1 as const, generatedAt: 'now', rows: [], nextAfter: null }; - it('passes a positive cursor through', () => { - const exp = vi.fn().mockReturnValue(page); - makeController({ export: exp }).export('42'); + it('passes a positive cursor through', async () => { + const exp = vi.fn().mockResolvedValue(page); + await makeController({ export: exp }).export('42'); expect(exp).toHaveBeenCalledWith(42); }); - it('starts from the beginning for anything that is not a positive integer', () => { - const exp = vi.fn().mockReturnValue(page); + it('starts from the beginning for anything that is not a positive integer', async () => { + const exp = vi.fn().mockResolvedValue(page); const c = makeController({ export: exp }); for (const bad of [undefined, '', 'abc', '0', '-5', '1.5', '1e400']) { - c.export(bad); + await c.export(bad); } expect(exp.mock.calls.every(([arg]) => arg === undefined)).toBe(true); }); }); describe('DELETE /api/place-shadow', () => { - it('reports how many rows went', () => { - expect(makeController({ clear: vi.fn().mockReturnValue(17) }).clear()).toEqual({ removed: 17 }); + it('reports how many rows went', async () => { + expect(await makeController({ clear: vi.fn().mockResolvedValue(17) }).clear()).toEqual({ removed: 17 }); }); }); describe('GET /api/place-shadow/summary', () => { - it('hands the service summary straight back', () => { + it('hands the service summary straight back', async () => { const summary = { enabled: true, total: 3 }; - expect(makeController({ summary: vi.fn().mockReturnValue(summary) }).summary()).toBe(summary); + expect(await makeController({ summary: vi.fn().mockResolvedValue(summary) }).summary()).toBe(summary); }); }); }); diff --git a/server/tests/unit/nest/place-shadow.job.test.ts b/server/tests/unit/nest/place-shadow.job.test.ts index b400ad9ec..a94bf01dd 100644 --- a/server/tests/unit/nest/place-shadow.job.test.ts +++ b/server/tests/unit/nest/place-shadow.job.test.ts @@ -25,40 +25,40 @@ function makeJob(shadow: Partial, registrar = registrarStub( beforeEach(() => vi.clearAllMocks()); describe('PlaceShadowRetentionJob', () => { - it('registers at 3:40, clear of the 3:00 idempotency purge', () => { - const { job, registrar } = makeJob({ purgeExpired: vi.fn(() => 0) }); - job.onApplicationBootstrap(); + it('registers at 3:40, clear of the 3:00 idempotency purge', async () => { + const { job, registrar } = makeJob({ purgeExpired: vi.fn(async () => 0) }); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('place-shadow-retention', '40 3 * * *', expect.any(Function)); }); - it('does not register under the test gate', () => { + it('does not register under the test gate', async () => { const { job, registrar } = makeJob({ purgeExpired: vi.fn() }, registrarStub(false)); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).not.toHaveBeenCalled(); }); - it('logs only when it actually removed something', () => { - const { job } = makeJob({ purgeExpired: vi.fn(() => 0) }); - job.tick(); + it('logs only when it actually removed something', async () => { + const { job } = makeJob({ purgeExpired: vi.fn(async () => 0) }); + await job.tick(); expect(logMock.logInfo).not.toHaveBeenCalled(); - const loud = makeJob({ purgeExpired: vi.fn(() => 12) }); - loud.job.tick(); + const loud = makeJob({ purgeExpired: vi.fn(async () => 12) }); + await loud.job.tick(); expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringContaining('12')); }); - it('contains a failing purge instead of letting it escape into the scheduler', () => { + it('contains a failing purge instead of letting it escape into the scheduler', async () => { const { job } = makeJob({ purgeExpired: vi.fn(() => { throw new Error('database is locked'); }) }); - expect(() => job.tick()).not.toThrow(); + await expect(job.tick()).resolves.toBeUndefined(); expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('database is locked')); }); - it('runs the purge even while the log itself is switched off', () => { + it('runs the purge even while the log itself is switched off', async () => { // Switching collection off should let what it already gathered age out, so // the job must not consult `enabled()` before purging. - const enabled = vi.fn(() => false); - const purgeExpired = vi.fn(() => 3); - makeJob({ enabled, purgeExpired }).job.tick(); + const enabled = vi.fn(async () => false); + const purgeExpired = vi.fn(async () => 3); + await makeJob({ enabled, purgeExpired }).job.tick(); expect(purgeExpired).toHaveBeenCalled(); expect(enabled).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/place-shadow.service.test.ts b/server/tests/unit/nest/place-shadow.service.test.ts index 6f1a8d268..b8c0ab51d 100644 --- a/server/tests/unit/nest/place-shadow.service.test.ts +++ b/server/tests/unit/nest/place-shadow.service.test.ts @@ -63,7 +63,7 @@ function enable(on = true) { .run('place_shadow_enabled', on ? 'true' : 'false'); } -beforeEach(() => { +beforeEach(async () => { conn = new Database(':memory:'); conn.exec(SCHEMA); svc = new PlaceShadowService(dbFacade(conn)); @@ -71,32 +71,32 @@ beforeEach(() => { describe('PlaceShadowService', () => { describe('the switch', () => { - it('is off when the setting row is absent', () => { - expect(svc.enabled()).toBe(false); - expect(svc.record(PICK)).toBe(false); + it('is off when the setting row is absent', async () => { + expect(await svc.enabled()).toBe(false); + expect(await svc.record(PICK)).toBe(false); expect(conn.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 }); }); - it('is off for any value that is not exactly "true"', () => { + it('is off for any value that is not exactly "true"', async () => { for (const value of ['false', '1', 'yes', 'TRUE', '']) { conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)') .run('place_shadow_enabled', value); - expect(svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false); + expect(await svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false); } }); - it('writes once switched on', () => { + it('writes once switched on', async () => { enable(); - expect(svc.record(PICK)).toBe(true); - expect(svc.summary().total).toBe(1); + expect(await svc.record(PICK)).toBe(true); + expect((await svc.summary()).total).toBe(1); }); }); describe('record', () => { beforeEach(() => enable()); - it('rounds every coordinate to three decimals', () => { - svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 }); + it('rounds every coordinate to three decimals', async () => { + await svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 }); const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record; expect(row.picked_lat).toBe(54.089); expect(row.picked_lng).toBe(12.137); @@ -104,34 +104,36 @@ describe('PlaceShadowService', () => { expect(row.bias_lng).toBe(12.14); }); - it('stores an absent bias and place id as NULL rather than inventing zeroes', () => { - svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined }); + it('stores an absent bias and place id as NULL rather than inventing zeroes', async () => { + await svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined }); const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record; expect(row.bias_lat).toBeNull(); expect(row.bias_lng).toBeNull(); expect(row.picked_place_id).toBeNull(); }); - it('drops a row whose rank is outside the list it claims to come from', () => { - expect(svc.record({ ...PICK, liveRank: 8, liveCount: 8 })).toBe(false); - expect(svc.record({ ...PICK, liveRank: 99, liveCount: 3 })).toBe(false); - expect(svc.summary().total).toBe(0); + it('drops a row whose rank is outside the list it claims to come from', async () => { + expect(await svc.record({ ...PICK, liveRank: 8, liveCount: 8 })).toBe(false); + expect(await svc.record({ ...PICK, liveRank: 99, liveCount: 3 })).toBe(false); + expect((await svc.summary()).total).toBe(0); // The boundary case one below is a legitimate last-result pick. - expect(svc.record({ ...PICK, liveRank: 7, liveCount: 8 })).toBe(true); + expect(await svc.record({ ...PICK, liveRank: 7, liveCount: 8 })).toBe(true); }); }); describe('summary', () => { - beforeEach(() => { + beforeEach(async () => { enable(); const ranks = [0, 0, 0, 1, 4, 5, 9, 10, 42]; - ranks.forEach((rank, i) => - svc.record({ ...PICK, liveRank: rank, liveCount: 50, source: i % 2 ? 'search:nominatim' : 'autocomplete:google' }), - ); + // Sequential, not Promise.all: the rows are inserted in rank order and the + // export/paging assertions below read that order back. + for (const [i, rank] of ranks.entries()) { + await svc.record({ ...PICK, liveRank: rank, liveCount: 50, source: i % 2 ? 'search:nominatim' : 'autocomplete:google' }); + } }); - it('buckets the live rank instead of averaging it', () => { - expect(svc.summary().liveRankBuckets).toEqual([ + it('buckets the live rank instead of averaging it', async () => { + expect((await svc.summary()).liveRankBuckets).toEqual([ { bucket: '1', count: 3 }, { bucket: '2-5', count: 2 }, { bucket: '6-10', count: 2 }, @@ -139,56 +141,56 @@ describe('PlaceShadowService', () => { ]); }); - it('reports the top-one and top-five shares over the whole corpus', () => { - const s = svc.summary(); + it('reports the top-one and top-five shares over the whole corpus', async () => { + const s = await svc.summary(); expect(s.total).toBe(9); expect(s.liveTopOneShare).toBeCloseTo(3 / 9, 6); expect(s.liveTopFiveShare).toBeCloseTo(5 / 9, 6); }); - it('counts by source, busiest first', () => { - expect(svc.summary().bySource).toEqual([ + it('counts by source, busiest first', async () => { + expect((await svc.summary()).bySource).toEqual([ { source: 'autocomplete:google', count: 5 }, { source: 'search:nominatim', count: 4 }, ]); }); - it('answers on an empty corpus without dividing by zero', () => { + it('answers on an empty corpus without dividing by zero', async () => { conn.exec('DELETE FROM place_shadow_picks'); - const s = svc.summary(); + const s = await svc.summary(); expect(s).toMatchObject({ total: 0, liveTopOneShare: 0, liveTopFiveShare: 0, oldest: null, newest: null }); expect(s.retentionDays).toBe(RETENTION_DAYS); }); - it('reports the switch state it was asked about', () => { - expect(svc.summary().enabled).toBe(true); + it('reports the switch state it was asked about', async () => { + expect((await svc.summary()).enabled).toBe(true); enable(false); - expect(svc.summary().enabled).toBe(false); + expect((await svc.summary()).enabled).toBe(false); }); }); describe('export', () => { - beforeEach(() => { + beforeEach(async () => { enable(); - for (let i = 0; i < 7; i++) svc.record({ ...PICK, query: `q${i}` }); + for (let i = 0; i < 7; i++) await svc.record({ ...PICK, query: `q${i}` }); }); - it('pages by id and hands back the cursor for the next page', () => { - const first = svc.export(undefined, 3); + it('pages by id and hands back the cursor for the next page', async () => { + const first = await svc.export(undefined, 3); expect(first.rows.map(r => r.query)).toEqual(['q0', 'q1', 'q2']); expect(first.nextAfter).toBe(first.rows[2].id); - const second = svc.export(first.nextAfter ?? undefined, 3); + const second = await svc.export(first.nextAfter ?? undefined, 3); expect(second.rows.map(r => r.query)).toEqual(['q3', 'q4', 'q5']); - const third = svc.export(second.nextAfter ?? undefined, 3); + const third = await svc.export(second.nextAfter ?? undefined, 3); expect(third.rows.map(r => r.query)).toEqual(['q6']); // Nothing beyond, so no cursor — that is how a reader knows to stop. expect(third.nextAfter).toBeNull(); }); - it('maps snake_case columns onto the camelCase contract', () => { - const row = svc.export(undefined, 1).rows[0]; + it('maps snake_case columns onto the camelCase contract', async () => { + const row = (await svc.export(undefined, 1)).rows[0]; expect(row).toMatchObject({ query: 'q0', lang: 'de', @@ -201,41 +203,41 @@ describe('PlaceShadowService', () => { expect(typeof row.createdAt).toBe('string'); }); - it('stamps the version so an evaluator can refuse a dump it does not know', () => { - expect(svc.export().version).toBe(1); + it('stamps the version so an evaluator can refuse a dump it does not know', async () => { + expect((await svc.export()).version).toBe(1); }); }); describe('retention and wipe', () => { beforeEach(() => enable()); - it('removes only rows past the window', () => { - svc.record(PICK); - svc.record(PICK); + it('removes only rows past the window', async () => { + await svc.record(PICK); + await svc.record(PICK); conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = 1").run(); - expect(svc.purgeExpired()).toBe(1); - expect(svc.summary().total).toBe(1); + expect(await svc.purgeExpired()).toBe(1); + expect((await svc.summary()).total).toBe(1); }); - it('keeps a row that is one day short of the window', () => { - svc.record(PICK); + it('keeps a row that is one day short of the window', async () => { + await svc.record(PICK); conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)") .run(`-${RETENTION_DAYS - 1} days`); - expect(svc.purgeExpired()).toBe(0); + expect(await svc.purgeExpired()).toBe(0); }); - it('runs even while the log is switched off, so old rows still age out', () => { - svc.record(PICK); + it('runs even while the log is switched off, so old rows still age out', async () => { + await svc.record(PICK); conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run(); enable(false); - expect(svc.purgeExpired()).toBe(1); + expect(await svc.purgeExpired()).toBe(1); }); - it('clear() empties the table and reports how many went', () => { - svc.record(PICK); - svc.record(PICK); - expect(svc.clear()).toBe(2); - expect(svc.summary().total).toBe(0); + it('clear() empties the table and reports how many went', async () => { + await svc.record(PICK); + await svc.record(PICK); + expect(await svc.clear()).toBe(2); + expect((await svc.summary()).total).toBe(0); }); }); }); diff --git a/server/tests/unit/nest/route-usage.service.test.ts b/server/tests/unit/nest/route-usage.service.test.ts index 01899bc4d..3dcbfe69e 100644 --- a/server/tests/unit/nest/route-usage.service.test.ts +++ b/server/tests/unit/nest/route-usage.service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; import { RouteUsageService, RETENTION_DAYS } from '../../../src/nest/route-usage/route-usage.service'; import type { RouteUsageEntry } from '@trek/shared'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; /** * SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router". @@ -27,14 +28,15 @@ function makeDb(): Database.Database { } /** The slice of DatabaseService this domain uses. */ -function serviceOver(db: Database.Database): RouteUsageService { +async function serviceOver(db: Database.Database): Promise { const bridge = { get: (sql: string, ...p: unknown[]) => db.prepare(sql).get(...p) as T | undefined, all: (sql: string, ...p: unknown[]) => db.prepare(sql).all(...p) as T[], run: (sql: string, ...p: unknown[]) => db.prepare(sql).run(...p), - transaction: (fn: () => T) => db.transaction(fn)(), }; - return new RouteUsageService(bridge as never); + // The batch transaction runs through MikroORM on this very handle now, so the + // raw statements inside the callback still land inside it. + return new RouteUsageService(bridge as never, await createTestUnitOfWork(db)); } const entry = (over: Partial = {}): RouteUsageEntry => ({ @@ -46,44 +48,44 @@ describe('RouteUsageService', () => { let db: Database.Database; let svc: RouteUsageService; - beforeEach(() => { + beforeEach(async () => { db = makeDb(); - svc = serviceOver(db); + svc = await serviceOver(db); }); - it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', () => { - expect(svc.enabled()).toBe(true); + it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', async () => { + expect(await svc.enabled()).toBe(true); db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); - expect(svc.enabled()).toBe(false); + expect(await svc.enabled()).toBe(false); }); - it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', () => { + it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', async () => { db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); - expect(svc.record({ entries: [entry()] })).toBe(false); + expect(await svc.record({ entries: [entry()] })).toBe(false); expect(db.prepare('SELECT COUNT(*) c FROM route_usage_daily').get()).toEqual({ c: 0 }); }); - it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', () => { - svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] }); - svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] }); + it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', async () => { + await svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] }); + await svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] }); - const rows = svc.rows(); + const rows = await svc.rows(); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ requests: 8, waypoints: 18, km: 450, failed: 1 }); }); - it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', () => { - svc.record({ entries: [ + it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', async () => { + await svc.record({ entries: [ entry({ surface: 'legs' }), entry({ surface: 'alternatives' }), entry({ surface: 'legs', selfHosted: true }), entry({ surface: 'legs', profile: 'walking' }), ] }); - expect(svc.rows()).toHaveLength(4); + expect(await svc.rows()).toHaveLength(4); }); - it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', () => { - const s = svc.summary(); + it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', async () => { + const s = await svc.summary(); expect(s.totalRequests).toBe(0); expect(s.daysCovered).toBe(0); expect(s.busiestDay).toBeNull(); @@ -91,12 +93,12 @@ describe('RouteUsageService', () => { expect(s.retentionDays).toBe(RETENTION_DAYS); }); - it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', () => { + it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', async () => { // Two days by hand: the service always writes "today", so the spread is set here. db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,10,30,900,0)`).run(); db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-02','driving','legs',0,30,60,1800,2)`).run(); - const s = svc.summary(); + const s = await svc.summary(); expect(s.totalRequests).toBe(40); expect(s.totalFailed).toBe(2); expect(s.daysCovered).toBe(2); @@ -107,30 +109,30 @@ describe('RouteUsageService', () => { expect(s.lastDay).toBe('2026-09-02'); }); - it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', () => { + it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', async () => { db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,4,20,400,0)`).run(); - const s = svc.summary(); + const s = await svc.summary(); expect(s.waypointsPerRequest).toBe(5); expect(s.kmPerRequest).toBe(100); }); - it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', () => { + it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', async () => { db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,30,0,0,0)`).run(); db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',1,10,0,0,0)`).run(); - expect(svc.summary().selfHostedShare).toBe(0.25); + expect((await svc.summary()).selfHostedShare).toBe(0.25); }); - it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', () => { + it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', async () => { db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-1 day'),'driving','legs',0,1,0,0,0)`).run(); db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-${RETENTION_DAYS + 5} days'),'driving','legs',0,1,0,0,0)`).run(); - expect(svc.purgeExpired()).toBe(1); - expect(svc.rows()).toHaveLength(1); + expect(await svc.purgeExpired()).toBe(1); + expect(await svc.rows()).toHaveLength(1); }); - it('SRV-ROUTEUSAGE-010: clearing wipes every counter', () => { - svc.record({ entries: [entry(), entry({ surface: 'route' })] }); - expect(svc.clear()).toBe(2); - expect(svc.rows()).toHaveLength(0); + it('SRV-ROUTEUSAGE-010: clearing wipes every counter', async () => { + await svc.record({ entries: [entry(), entry({ surface: 'route' })] }); + expect(await svc.clear()).toBe(2); + expect(await svc.rows()).toHaveLength(0); }); }); diff --git a/server/tests/unit/nest/storage/storage-admin.controller.test.ts b/server/tests/unit/nest/storage/storage-admin.controller.test.ts index fd8cd4c64..fae15903e 100644 --- a/server/tests/unit/nest/storage/storage-admin.controller.test.ts +++ b/server/tests/unit/nest/storage/storage-admin.controller.test.ts @@ -31,7 +31,7 @@ const FRESH_STATE = { function makeController(over: Partial> = {}) { const writeAudit = vi.fn(); const service = { - state: vi.fn(() => FRESH_STATE), + state: vi.fn(async () => FRESH_STATE), applyConfig: vi.fn(), ...over, } as unknown as StorageAdminService; @@ -61,9 +61,9 @@ const CONFIG = { } as StorageConfigDto; describe('StorageAdminController', () => { - it('STORCTL-001 GET returns the service state untouched', () => { + it('STORCTL-001 GET returns the service state untouched', async () => { const { controller, service } = makeController(); - expect(controller.get()).toBe(FRESH_STATE); + expect(await controller.get()).toBe(FRESH_STATE); expect(service.state).toHaveBeenCalledTimes(1); }); diff --git a/server/tests/unit/nest/storage/storage-admin.service.test.ts b/server/tests/unit/nest/storage/storage-admin.service.test.ts index d4c7f3fe8..e163d7098 100644 --- a/server/tests/unit/nest/storage/storage-admin.service.test.ts +++ b/server/tests/unit/nest/storage/storage-admin.service.test.ts @@ -27,6 +27,7 @@ import { StorageStatsService } from '../../../../src/nest/storage/storage-stats. import { StorageRegistryService, BACKENDS_KEY, CATEGORIES_KEY } from '../../../../src/nest/storage/storage-registry.service'; import { StorageService } from '../../../../src/nest/storage/storage.service'; import { StorageConflictError } from '../../../../src/nest/storage/storage.types'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; const db = new DatabaseService(testDb); @@ -61,17 +62,18 @@ const S3_OPTIONS = { }; /** Real registry + real service over the in-memory DB. */ -function makeService(opts: { uploadsRoot?: string } = {}) { +async function makeService(opts: { uploadsRoot?: string } = {}) { const uploadsRoot = opts.uploadsRoot ?? makeTmpDir(); setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }])); const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const uow = await createTestUnitOfWork(testDb); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), uow); + await registry.onModuleInit(); const storage = new StorageService(registry); const jobs = new StorageJobsService(registry); const stats = new StorageStatsService(storage, db); - const service = new StorageAdminService(db, registry, storage, jobs, stats); - return { service, registry, uploadsRoot, stats, jobs }; + const service = new StorageAdminService(db, registry, storage, jobs, stats, uow); + return { service, registry, uploadsRoot, stats, jobs, uow }; } /** The settings-owned document the service persists (uploads override + extras). */ @@ -90,8 +92,8 @@ function configWith(uploadsRoot: string, extra: Partial = {}): St * audit #7) — most tests just want "the current one", read fresh at call * time so a second call in the same test picks up the bump from the first. */ -function put(service: StorageAdminService, config: StorageConfig): void { - service.applyConfig({ ...config, version: service.state().version }); +async function put(service: StorageAdminService, config: StorageConfig): Promise { + await service.applyConfig({ ...config, version: (await (await service.state())).version }); } beforeEach(() => { @@ -103,9 +105,9 @@ afterEach(() => { }); describe('StorageAdminService.state', () => { - it('STORADM-001 renders the effective world: sources, categories-per-backend, flags', () => { - const { service, uploadsRoot } = makeService(); - const state = service.state(); + it('STORADM-001 renders the effective world: sources, categories-per-backend, flags', async () => { + const { service, uploadsRoot } = await makeService(); + const state = (await service.state()); const uploads = state.backends.find((b) => b.name === 'uploads-local')!; expect(uploads).toMatchObject({ type: 'local', source: 'settings', options: { root: uploadsRoot } }); expect(uploads.categories).toContain('files'); @@ -116,51 +118,51 @@ describe('StorageAdminService.state', () => { expect(state.health).toEqual({ replicaFailures: [] }); }); - it('STORADM-002 masks exactly the secret fields (accessKeyId stays visible)', () => { - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + it('STORADM-002 masks exactly the secret fields (accessKeyId stays visible)', async () => { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: { backups: 'off-box' }, })); - const offBox = service.state().backends.find((b) => b.name === 'off-box')!; + const offBox = (await service.state()).backends.find((b) => b.name === 'off-box')!; expect(offBox.options.secretAccessKey).toBe(MASKED_SETTING_VALUE); expect(offBox.options.accessKeyId).toBe('ak'); }); - it('STORADM-003 surfaces replica failures through StorageService.health()', () => { - const { service, registry } = makeService(); + it('STORADM-003 surfaces replica failures through StorageService.health()', async () => { + const { service, registry } = await makeService(); registry.recordReplicaFailure({ backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 123 }); - expect(service.state().health.replicaFailures).toEqual([ + expect((await service.state()).health.replicaFailures).toEqual([ { backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 123 }, ]); }); - it('STORADM-027 configError is null on a clean load, and mirrors registry.lastLoadError() after a bad reload', () => { + it('STORADM-027 configError is null on a clean load, and mirrors registry.lastLoadError() after a bad reload', async () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { service, registry } = makeService(); - expect(service.state().configError).toBeNull(); + const { service, registry } = await makeService(); + expect((await service.state()).configError).toBeNull(); setSetting(CATEGORIES_KEY, 'garbage {'); - registry.reload(); - expect(service.state().configError).toBe(registry.lastLoadError()); - expect(service.state().configError).not.toBeNull(); + await registry.reload(); + expect((await service.state()).configError).toBe(registry.lastLoadError()); + expect((await service.state()).configError).not.toBeNull(); }); it('STORADM-026 state embeds usage (null until computed) and live backfill statuses', async () => { - const { service, stats } = makeService(); - expect(service.state().usage).toBeNull(); - expect(service.state().backfills).toEqual([]); + const { service, stats } = await makeService(); + expect((await service.state()).usage).toBeNull(); + expect((await service.state()).backfills).toEqual([]); await stats.scan(); - expect(service.state().usage).not.toBeNull(); - expect(service.state().usage!.computedAt).toBeGreaterThan(0); + expect((await service.state()).usage).not.toBeNull(); + expect((await service.state()).usage!.computedAt).toBeGreaterThan(0); }); }); describe('StorageAdminService.applyConfig', () => { - it('STORADM-010 happy path: persists both rows, reloads, new config is live', () => { - const { service, registry, uploadsRoot } = makeService(); + it('STORADM-010 happy path: persists both rows, reloads, new config is live', async () => { + const { service, registry, uploadsRoot } = await makeService(); const nasRoot = makeTmpDir(); - put(service, configWith(uploadsRoot, { + await put(service, configWith(uploadsRoot, { backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }], categories: { backups: 'nas-backups' }, })); @@ -168,9 +170,9 @@ describe('StorageAdminService.applyConfig', () => { expect(JSON.parse(readRow(CATEGORIES_KEY)!)).toEqual({ backups: 'nas-backups' }); }); - it('STORADM-011 encrypts plaintext secrets at rest', () => { - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + it('STORADM-011 encrypts plaintext secrets at rest', async () => { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: { backups: 'off-box' }, })); @@ -179,16 +181,16 @@ describe('StorageAdminService.applyConfig', () => { expect(secret.startsWith('enc:v1:')).toBe(true); }); - it('STORADM-012 mask echo preserves the stored ciphertext byte-for-byte', () => { - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + it('STORADM-012 mask echo preserves the stored ciphertext byte-for-byte', async () => { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: { backups: 'off-box' }, })); const before = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record }>; const cipherBefore = before.find((b) => b.name === 'off-box')!.options.secretAccessKey; - put(service, configWith(uploadsRoot, { + await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }], categories: { backups: 'off-box' }, })); @@ -196,22 +198,22 @@ describe('StorageAdminService.applyConfig', () => { expect(after.find((b) => b.name === 'off-box')!.options.secretAccessKey).toBe(cipherBefore); }); - it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', () => { - const { service, uploadsRoot } = makeService(); + it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', async () => { + const { service, uploadsRoot } = await makeService(); const before = readRow(BACKENDS_KEY); - expect(() => + await expect( put(service, configWith(uploadsRoot, { backends: [{ name: 'brand-new', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }], categories: {}, })), - ).toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'"); + ).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'"); expect(readRow(BACKENDS_KEY)).toBe(before); }); - it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', () => { + it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', async () => { // No key-presence gate: the implicit key covers encryption when ENCRYPTION_KEY is unset. - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: {}, })); @@ -220,52 +222,53 @@ describe('StorageAdminService.applyConfig', () => { expect(offBox.options.secretAccessKey.startsWith('enc:v1:')).toBe(true); }); - it('STORADM-015 an encrypted (mask-echoed or enc:v1:) secret resaves fine', () => { + it('STORADM-015 an encrypted (mask-echoed or enc:v1:) secret resaves fine', async () => { // Resaving stored ciphertext must not lock admins out. - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } }], categories: { backups: 'off-box' }, })); expect(readRow(BACKENDS_KEY)).toBeDefined(); }); - it('STORADM-016 preview() refusals surface verbatim and persist nothing', () => { - const { service, uploadsRoot, registry } = makeService(); + it('STORADM-016 preview() refusals surface verbatim and persist nothing', async () => { + const { service, uploadsRoot, registry } = await makeService(); const before = readRow(CATEGORIES_KEY); - expect(() => + await expect( put(service, configWith(uploadsRoot, { categories: { backups: 'nope' } })), - ).toThrow("category 'backups' maps to unknown backend 'nope'"); + ).rejects.toThrow("category 'backups' maps to unknown backend 'nope'"); expect(readRow(CATEGORIES_KEY)).toBe(before); expect(registry.resolve('backups').backendName).toBe('backups-local'); // live state untouched }); - it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', () => { - const { service, uploadsRoot } = makeService(); + it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', async () => { + const { service, uploadsRoot } = await makeService(); const before = readRow(BACKENDS_KEY); - expect(() => + await expect( put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }], categories: {}, })), - ).toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value"); + ).rejects.toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value"); expect(readRow(BACKENDS_KEY)).toBe(before); }); - it('STORADM-017 persists both rows in ONE transaction and reloads once', () => { - const { service, registry, uploadsRoot } = makeService(); - const txSpy = vi.spyOn(db, 'transaction'); + it('STORADM-017 persists both rows in ONE transaction and reloads once', async () => { + const { service, registry, uploadsRoot, uow } = await makeService(); + // The one transaction is now UnitOfWork.transactional, not db.transaction. + const txSpy = vi.spyOn(uow, 'transactional'); const reloadSpy = vi.spyOn(registry, 'reload'); - put(service, configWith(uploadsRoot)); + await put(service, configWith(uploadsRoot)); expect(txSpy).toHaveBeenCalledTimes(1); expect(reloadSpy).toHaveBeenCalledTimes(1); }); - it('STORADM-019 applyConfig invokes the dissolved-job cancellation after reload', () => { - const { service, registry, uploadsRoot, jobs } = makeService(); + it('STORADM-019 applyConfig invokes the dissolved-job cancellation after reload', async () => { + const { service, registry, uploadsRoot, jobs } = await makeService(); const reloadSpy = vi.spyOn(registry, 'reload'); const cancelSpy = vi.spyOn(jobs, 'cancelJobsForMissingBackends'); - put(service, configWith(uploadsRoot)); + await put(service, configWith(uploadsRoot)); expect(cancelSpy).toHaveBeenCalledTimes(1); expect(reloadSpy).toHaveBeenCalledTimes(1); const reloadOrder = reloadSpy.mock.invocationCallOrder[0]!; @@ -273,25 +276,25 @@ describe('StorageAdminService.applyConfig', () => { expect(cancelOrder).toBeGreaterThan(reloadOrder); }); - it('STORADM-030 a successful save bumps the version counter by exactly one', () => { - const { service, uploadsRoot } = makeService(); - expect(service.state().version).toBe(0); - put(service, configWith(uploadsRoot)); - expect(service.state().version).toBe(1); - put(service, configWith(uploadsRoot)); - expect(service.state().version).toBe(2); + it('STORADM-030 a successful save bumps the version counter by exactly one', async () => { + const { service, uploadsRoot } = await makeService(); + expect((await service.state()).version).toBe(0); + await put(service, configWith(uploadsRoot)); + expect((await service.state()).version).toBe(1); + await put(service, configWith(uploadsRoot)); + expect((await service.state()).version).toBe(2); }); - it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', () => { - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot)); // version is now 1 + it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', async () => { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot)); // version is now 1 const beforeBackends = readRow(BACKENDS_KEY); const beforeCategories = readRow(CATEGORIES_KEY); - expect(() => + await expect( service.applyConfig({ ...configWith(uploadsRoot), version: 0 }), // stale — current is 1 - ).toThrow(StorageConflictError); + ).rejects.toThrow(StorageConflictError); try { - service.applyConfig({ ...configWith(uploadsRoot), version: 0 }); + await service.applyConfig({ ...configWith(uploadsRoot), version: 0 }); expect.unreachable('should have thrown'); } catch (err) { expect(err).toBeInstanceOf(StorageConflictError); @@ -300,24 +303,24 @@ describe('StorageAdminService.applyConfig', () => { } expect(readRow(BACKENDS_KEY)).toBe(beforeBackends); expect(readRow(CATEGORIES_KEY)).toBe(beforeCategories); - expect(service.state().version).toBe(1); // unchanged — the conflicting save never wrote + expect((await service.state()).version).toBe(1); // unchanged — the conflicting save never wrote }); - it('STORADM-032 the version check runs before preview/unmask — a stale submit never reaches those refusals', () => { + it('STORADM-032 the version check runs before preview/unmask — a stale submit never reaches those refusals', async () => { // If the version check ran AFTER preview, this would throw the registry's // "unknown backend" StorageBackendError instead of StorageConflictError. - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot)); // version is now 1 - expect(() => + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot)); // version is now 1 + await expect( service.applyConfig({ ...configWith(uploadsRoot, { categories: { backups: 'nope' } }), version: 0 }), - ).toThrow(StorageConflictError); + ).rejects.toThrow(StorageConflictError); }); - it('STORADM-033 regression (audit #7): a migration flip bumps the version; a stale admin PUT built before the flip 409s and the flip survives untouched', () => { - const { service, registry, uploadsRoot } = makeService(); + it('STORADM-033 regression (audit #7): a migration flip bumps the version; a stale admin PUT built before the flip 409s and the flip survives untouched', async () => { + const { service, registry, uploadsRoot } = await makeService(); const destRoot = makeTmpDir(); // The admin loads the form: draft carries version 0, categories.files → uploads-local. - put(service, configWith(uploadsRoot, { + await put(service, configWith(uploadsRoot, { backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }], categories: {}, })); // version is now 1; 'files' still defaults to uploads-local @@ -325,33 +328,33 @@ describe('StorageAdminService.applyConfig', () => { backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }], categories: {}, // the admin's draft never touched 'files' }); - const staleVersion = service.state().version; // 1 — what the admin's form is holding + const staleVersion = (await service.state()).version; // 1 — what the admin's form is holding // Meanwhile, a category migration flips 'files' to 'dest' — the exact // write path assignCategory uses, bumping the shared version counter. - registry.assignCategory('files', 'dest'); + await registry.assignCategory('files', 'dest'); expect(registry.snapshot().categories.files.backend).toBe('dest'); - expect(service.state().version).toBe(2); // the flip bumped it past the admin's stale draft + expect((await service.state()).version).toBe(2); // the flip bumped it past the admin's stale draft // The admin's stale PUT (still holding version 1, and no opinion on // 'files') must 409, NOT silently reassign 'files' back to its old default. - expect(() => service.applyConfig({ ...staleDraft, version: staleVersion })).toThrow(StorageConflictError); + await expect(service.applyConfig({ ...staleDraft, version: staleVersion })).rejects.toThrow(StorageConflictError); // The flip survives untouched. expect(registry.snapshot().categories.files.backend).toBe('dest'); - expect(service.state().version).toBe(2); + expect((await service.state()).version).toBe(2); }); }); describe('StorageAdminService.testBackend', () => { it('STORADM-020 probes a healthy local candidate: ok with one green target', async () => { - const { service } = makeService(); + const { service } = await makeService(); const result = await service.testBackend({ name: 'cand', type: 'local', options: { root: makeTmpDir() } }); expect(result).toEqual({ ok: true, targets: [{ name: 'cand', ok: true }] }); }); it('STORADM-021 an unreachable s3 candidate fails fast with a per-target error (no registry impact)', async () => { - const { service, registry } = makeService(); + const { service, registry } = await makeService(); const result = await service.testBackend({ name: 'cand', type: 's3', @@ -381,8 +384,8 @@ describe('StorageAdminService.testBackend', () => { // corrupt it afterward — testBackend's ephemeralDriverFor re-runs // init() fresh at probe time and hits the same EEXIST there instead. const badRoot = path.join(makeTmpDir(), 'a-file'); - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [ { name: 'good-local', type: 'local', options: { root: goodRoot } }, { name: 'bad-local', type: 'local', options: { root: badRoot } }, @@ -404,16 +407,16 @@ describe('StorageAdminService.testBackend', () => { }); it('STORADM-023 a mirror referencing an unknown or mirror-typed backend throws (→ 400)', async () => { - const { service } = makeService(); + const { service } = await makeService(); await expect( service.testBackend({ name: 'm', type: 'mirror', options: { primary: 'nope', replicas: [] } }), ).rejects.toThrow("mirror 'm' references unknown backend 'nope'"); }); it('STORADM-024 unmasks a stored backend by name before probing (mask echo works on /test)', async () => { - const { service, uploadsRoot } = makeService(); + const { service, uploadsRoot } = await makeService(); const root = makeTmpDir(); - put(service, configWith(uploadsRoot, { + await put(service, configWith(uploadsRoot, { backends: [{ name: 'nas', type: 'local', options: { root } }], categories: {}, })); @@ -438,8 +441,8 @@ describe('StorageAdminService.testBackend', () => { it('STORADM-025 a mirror with a stored s3 replica decrypts the enc:v1: secret for the probe', async () => { const goodRoot = makeTmpDir(); - const { service, uploadsRoot } = makeService(); - put(service, configWith(uploadsRoot, { + const { service, uploadsRoot } = await makeService(); + await put(service, configWith(uploadsRoot, { backends: [ { name: 'good-local', type: 'local', options: { root: goodRoot } }, { diff --git a/server/tests/unit/nest/storage/storage-jobs.service.test.ts b/server/tests/unit/nest/storage/storage-jobs.service.test.ts index f4e3b2798..646adb5a9 100644 --- a/server/tests/unit/nest/storage/storage-jobs.service.test.ts +++ b/server/tests/unit/nest/storage/storage-jobs.service.test.ts @@ -30,6 +30,7 @@ import { MigrationTargetError, StorageJobsService, } from '../../../../src/nest/storage/storage-jobs.service'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; const db = new DatabaseService(testDb); @@ -56,7 +57,7 @@ afterEach(() => { }); /** A registry whose backups category routes through mirror 'm' (nas replica). */ -function makeWorld() { +async function makeWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); const nasRoot = makeTmpDir(); @@ -71,8 +72,8 @@ function makeWorld() { ); setSetting('storage.categories', JSON.stringify({ backups: 'm' })); const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); const jobs = new StorageJobsService(registry); return { registry, storage, jobs, backupsRoot, nasRoot }; @@ -87,7 +88,7 @@ async function waitFor(predicate: () => boolean, ms = 5000, intervalMs = 20): Pr } /** A registry with 'files' explicitly routed to 'uploads-local' and an unassigned 'dest-local' target. */ -function makeMigrationWorld() { +async function makeMigrationWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); const destRoot = makeTmpDir(); @@ -101,8 +102,8 @@ function makeMigrationWorld() { ); setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); const jobs = new StorageJobsService(registry); return { registry, storage, jobs, uploadsRoot, destRoot }; @@ -114,7 +115,7 @@ function makeMigrationWorld() { * plus an unassigned 'dest-local' target that uses the normal prefixed * (mode-B) layout for every other backend. */ -function makePhotosGoogleMigrationWorld() { +async function makePhotosGoogleMigrationWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); const placePhotoRoot = makeTmpDir(); @@ -128,15 +129,15 @@ function makePhotosGoogleMigrationWorld() { ]), ); const env = { env: () => ({ paths: { placePhotoDir: placePhotoRoot } }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); const jobs = new StorageJobsService(registry); return { registry, storage, jobs, placePhotoRoot, destRoot }; } /** Combines the migration world's 'dest-local' target with the backfill world's routed mirror 'm'. */ -function makeMigrationBackfillWorld() { +async function makeMigrationBackfillWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); const nasRoot = makeTmpDir(); @@ -153,8 +154,8 @@ function makeMigrationBackfillWorld() { ); setSetting('storage.categories', JSON.stringify({ backups: 'm', files: 'uploads-local' })); const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); const jobs = new StorageJobsService(registry); return { registry, storage, jobs, uploadsRoot, backupsRoot, nasRoot, destRoot }; @@ -176,7 +177,7 @@ async function waitTerminal(jobs: StorageJobsService, category: string) { describe('StorageJobsService', () => { it('JOBS-001 backfills the categories routed through the mirror and lands on done', async () => { - const { storage, jobs, nasRoot } = makeWorld(); + const { storage, jobs, nasRoot } = await makeWorld(); await storage.put('backups', 'old-backup.zip', Readable.from('zipzip')); fs.rmSync(path.join(nasRoot, 'old-backup.zip'), { force: true }); // simulate pre-mirror object jobs.startBackfill('m'); @@ -186,14 +187,14 @@ describe('StorageJobsService', () => { expect(fs.existsSync(path.join(nasRoot, 'old-backup.zip'))).toBe(true); }); - it('JOBS-002 rejects an unrouted or non-mirror name with BackfillTargetError', () => { - const { jobs } = makeWorld(); + it('JOBS-002 rejects an unrouted or non-mirror name with BackfillTargetError', async () => { + const { jobs } = await makeWorld(); expect(() => jobs.startBackfill('nas')).toThrow(BackfillTargetError); expect(() => jobs.startBackfill('ghost')).toThrow(BackfillTargetError); }); it('JOBS-003 a second start while one runs throws BackfillBusyError (global, either backend)', async () => { - const { storage, jobs } = makeWorld(); + const { storage, jobs } = await makeWorld(); for (let i = 0; i < 20; i++) await storage.put('backups', `b${i}.zip`, Readable.from('x'.repeat(1000))); jobs.startBackfill('m'); expect(() => jobs.startBackfill('m')).toThrow(BackfillBusyError); @@ -201,7 +202,7 @@ describe('StorageJobsService', () => { }); it('JOBS-004 cancel flips a running job to cancelled; cancelling a finished/unknown one returns false', async () => { - const { storage, jobs } = makeWorld(); + const { storage, jobs } = await makeWorld(); for (let i = 0; i < 50; i++) await storage.put('backups', `c${i}.zip`, Readable.from('y'.repeat(2000))); jobs.startBackfill('m'); expect(jobs.cancelBackfill('m')).toBe(true); @@ -214,7 +215,7 @@ describe('StorageJobsService', () => { it('JOBS-005 finished statuses expire after the TTL', async () => { vi.useFakeTimers(); try { - const { storage, jobs } = makeWorld(); + const { storage, jobs } = await makeWorld(); await storage.put('backups', 'one.zip', Readable.from('z')); jobs.startBackfill('m'); // Drain the real async job under fake timers by flushing microtasks. @@ -227,7 +228,7 @@ describe('StorageJobsService', () => { }); it('JOBS-006 an Error rejection from driver.backfill lands the job on "error" with its message, and is logged', async () => { - const { jobs } = makeWorld(); + const { jobs } = await makeWorld(); const backfillSpy = vi.spyOn(MirrorDriver.prototype, 'backfill').mockRejectedValueOnce(new Error('replica offline')); const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); jobs.startBackfill('m'); @@ -239,7 +240,7 @@ describe('StorageJobsService', () => { }); it('JOBS-006b a non-Error rejection is stringified rather than crashing', async () => { - const { jobs } = makeWorld(); + const { jobs } = await makeWorld(); const backfillSpy = vi.spyOn(MirrorDriver.prototype, 'backfill').mockRejectedValueOnce('replica gone'); vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); jobs.startBackfill('m'); @@ -249,7 +250,7 @@ describe('StorageJobsService', () => { }); it('JOBS-007 withTtl builds a service whose finished jobs expire on the given (short) TTL, not the 10-minute default', async () => { - const { registry, storage } = makeWorld(); + const { registry, storage } = await makeWorld(); await storage.put('backups', 'q.zip', Readable.from('q')); const jobs = StorageJobsService.withTtl(registry, 50); jobs.startBackfill('m'); @@ -259,7 +260,7 @@ describe('StorageJobsService', () => { }); it('JOBS-008 while a backfill is running, starting an unknown/non-mirror name throws BackfillTargetError, not BackfillBusyError', async () => { - const { storage, jobs } = makeWorld(); + const { storage, jobs } = await makeWorld(); for (let i = 0; i < 20; i++) await storage.put('backups', `d${i}.zip`, Readable.from('x'.repeat(1000))); jobs.startBackfill('m'); expect(() => jobs.startBackfill('ghost')).toThrow(BackfillTargetError); @@ -270,14 +271,14 @@ describe('StorageJobsService', () => { describe('StorageJobsService migrations', () => { it('MIG-001 happy path: copies, flips the category, sweeps a raced write, tallies reclaimable', async () => { - const { storage, jobs, uploadsRoot, destRoot } = makeMigrationWorld(); + const { storage, jobs, uploadsRoot, destRoot } = await makeMigrationWorld(); // Sized up (not slept) so the copy phase has real work left when we poll // for enumeration-done — a few bytes would finish before the first poll tick. const big = 'a'.repeat(4_000_000); await storage.put('files', 'a.txt', Readable.from(big)); await storage.put('files', 'b.txt', Readable.from(big.replace(/a/g, 'b'))); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); // Enumeration done — write the raced third object before the copy phase settles. // Tight poll interval: the window between "total > 0" and job completion is narrow. await waitFor( @@ -301,13 +302,13 @@ describe('StorageJobsService migrations', () => { }); it('MIG-002 a copy failure blocks the flip and ends failed', async () => { - const { storage, jobs, destRoot } = makeMigrationWorld(); + const { storage, jobs, destRoot } = await makeMigrationWorld(); await storage.put('files', 'a.txt', Readable.from('aaa')); await storage.put('files', 'b.txt', Readable.from('bbb')); // Pre-create a DIRECTORY at the target key path so the put's rename fails. fs.mkdirSync(path.join(destRoot, 'files', 'a.txt'), { recursive: true }); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('failed'); expect(final.failed).toBeGreaterThan(0); @@ -315,12 +316,12 @@ describe('StorageJobsService migrations', () => { }); it('MIG-003 cancel before the flip leaves everything untouched', async () => { - const { storage, jobs } = makeMigrationWorld(); + const { storage, jobs } = await makeMigrationWorld(); // Many, largish objects (not sleeps) so the copy phase is still running // when we poll for done >= 1 and issue the cancel. for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000))); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1); expect(jobs.cancelMigration('files')).toBe(true); @@ -333,13 +334,17 @@ describe('StorageJobsService migrations', () => { // Deterministic route (no timing race): an EMPTY category never enters // either copy loop, so the only place a cancel can be observed is the // guard between the failed-check and the flip. `cancelMigration` is - // called synchronously right after `startMigration` returns — the + // called synchronously right after `startMigration`'s own body runs — the // detached runMigration's first `for await` suspends on a microtask // before doing any work, so this synchronous call always lands the - // cancel flag before the async function resumes. - const { jobs } = makeMigrationWorld(); // 'files' has zero objects - jobs.startMigration('files', 'dest-local'); + // cancel flag before the async function resumes. `startMigration` is now + // async, so its promise is held and awaited AFTER the cancel rather than + // before it: awaiting first would hand runMigration a turn and the flip + // could land before the cancel. + const { jobs } = await makeMigrationWorld(); // 'files' has zero objects + const started = jobs.startMigration('files', 'dest-local'); expect(jobs.cancelMigration('files')).toBe(true); + await started; const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('cancelled'); @@ -347,33 +352,33 @@ describe('StorageJobsService migrations', () => { }); it('MIG-004 validations: 400s, 404, and 409 against a running backfill (and vice versa)', async () => { - const { storage, jobs } = makeMigrationBackfillWorld(); - expect(() => jobs.startMigration('files', 'uploads-local')).toThrow(MigrationRequestError); // to === current - expect(() => jobs.startMigration('nope' as never, 'dest-local')).toThrow(MigrationRequestError); - expect(() => jobs.startMigration('files', 'ghost')).toThrow(MigrationTargetError); + const { storage, jobs } = await makeMigrationBackfillWorld(); + await expect(jobs.startMigration('files', 'uploads-local')).rejects.toThrow(MigrationRequestError); // to === current + await expect(jobs.startMigration('nope' as never, 'dest-local')).rejects.toThrow(MigrationRequestError); + await expect(jobs.startMigration('files', 'ghost')).rejects.toThrow(MigrationTargetError); // A running backfill blocks a migration start. for (let i = 0; i < 20; i++) await storage.put('backups', `b${i}.zip`, Readable.from('x'.repeat(1000))); jobs.startBackfill('m'); - expect(() => jobs.startMigration('files', 'dest-local')).toThrow(BackfillBusyError); + await expect(jobs.startMigration('files', 'dest-local')).rejects.toThrow(BackfillBusyError); await waitFor(() => jobs.statuses().some((s) => s.status !== 'running')); // A running migration blocks a backfill start too. await storage.put('files', 'a.txt', Readable.from('a'.repeat(5000))); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); expect(() => jobs.startBackfill('m')).toThrow(BackfillBusyError); await waitFor(() => jobs.migrationStatuses().some((m) => m.status !== 'running')); }); it('MIG-005 an object already on the target with a matching size is skipped in the copy phase itself', async () => { - const { storage, jobs, destRoot } = makeMigrationWorld(); + const { storage, jobs, destRoot } = await makeMigrationWorld(); await storage.put('files', 'a.txt', Readable.from('aaa')); // Pre-populate the destination with a byte-identical copy — the shape of // a retried/resumed migration, not just the delta sweep's territory. fs.mkdirSync(path.join(destRoot, 'files'), { recursive: true }); fs.writeFileSync(path.join(destRoot, 'files', 'a.txt'), 'aaa'); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('done'); expect(final.skipped).toBeGreaterThanOrEqual(1); @@ -382,7 +387,7 @@ describe('StorageJobsService migrations', () => { }); it('MIG-006 a sweep-phase copy failure is reported without undoing the already-flipped category', async () => { - const { storage, jobs, registry, uploadsRoot, destRoot } = makeMigrationWorld(); + const { storage, jobs, registry, uploadsRoot, destRoot } = await makeMigrationWorld(); await storage.put('files', 'a.txt', Readable.from('aaa')); // Grab the exact driver instance startMigration will resolve and keep for @@ -404,7 +409,7 @@ describe('StorageJobsService migrations', () => { return originalList(prefix); }); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('done'); // sweep failures don't undo a completed flip expect(final.failed).toBeGreaterThanOrEqual(1); @@ -412,11 +417,11 @@ describe('StorageJobsService migrations', () => { }); it('MIG-008 photos-google mode-A (bare "" prefix) migrating to a prefixed backend rewrites destination keys (audit #8)', async () => { - const { storage, jobs, placePhotoRoot, destRoot } = makePhotosGoogleMigrationWorld(); + const { storage, jobs, placePhotoRoot, destRoot } = await makePhotosGoogleMigrationWorld(); await storage.put('photos-google', 'abc.jpg', Readable.from('img-a')); await storage.put('photos-google', 'sub/def.jpg', Readable.from('img-b')); - jobs.startMigration('photos-google', 'dest-local'); + await jobs.startMigration('photos-google', 'dest-local'); const final = await waitTerminal(jobs, 'photos-google'); expect(final.status).toBe('done'); @@ -434,7 +439,7 @@ describe('StorageJobsService migrations', () => { }); it('MIG-009 the skip-check on a rewritten-prefix migration stats the DESTINATION key, not the source key', async () => { - const { storage, jobs, destRoot } = makePhotosGoogleMigrationWorld(); + const { storage, jobs, destRoot } = await makePhotosGoogleMigrationWorld(); await storage.put('photos-google', 'abc.jpg', Readable.from('same-bytes')); // Pre-populate the destination at the REWRITTEN key with matching size — // if the skip-check mistakenly stat'd the bare source key on the target, @@ -442,7 +447,7 @@ describe('StorageJobsService migrations', () => { fs.mkdirSync(path.join(destRoot, 'photos', 'google'), { recursive: true }); fs.writeFileSync(path.join(destRoot, 'photos', 'google', 'abc.jpg'), 'same-bytes'); - jobs.startMigration('photos-google', 'dest-local'); + await jobs.startMigration('photos-google', 'dest-local'); const final = await waitTerminal(jobs, 'photos-google'); expect(final.status).toBe('done'); @@ -452,11 +457,11 @@ describe('StorageJobsService migrations', () => { }); it('MIG-010 the delta sweep rewrites a raced object\'s destination key too', async () => { - const { storage, jobs, placePhotoRoot, destRoot } = makePhotosGoogleMigrationWorld(); + const { storage, jobs, placePhotoRoot, destRoot } = await makePhotosGoogleMigrationWorld(); const big = 'x'.repeat(2_000_000); await storage.put('photos-google', 'a.jpg', Readable.from(big)); - jobs.startMigration('photos-google', 'dest-local'); + await jobs.startMigration('photos-google', 'dest-local'); await waitFor( () => jobs.migrationStatuses().some((m) => m.category === 'photos-google' && m.total > 0), 5000, @@ -474,10 +479,10 @@ describe('StorageJobsService migrations', () => { }); it('MIG-011 equal-prefix migrations (files -> dest-local) stay byte-identical: destination key equals the source key', async () => { - const { storage, jobs, destRoot } = makeMigrationWorld(); + const { storage, jobs, destRoot } = await makeMigrationWorld(); await storage.put('files', 'a.txt', Readable.from('aaa')); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('done'); @@ -491,7 +496,7 @@ describe('StorageJobsService migrations', () => { describe('StorageJobsService.cancelJobsForMissingBackends', () => { it('JOBS-020 cancels a running backfill whose mirror left the config', async () => { - const { storage, jobs, registry } = makeWorld(); + const { storage, jobs, registry } = await makeWorld(); for (let i = 0; i < 50; i++) await storage.put('backups', `c${i}.zip`, Readable.from('y'.repeat(2000))); jobs.startBackfill('m'); await waitFor(() => jobs.statuses().some((s) => s.backend === 'm' && s.done >= 1), 5000, 1); @@ -500,7 +505,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { // to the built-in default ('backups-local'), a self-consistent config. setSetting('storage.backends', JSON.stringify([])); setSetting('storage.categories', JSON.stringify({})); - registry.reload(); + await registry.reload(); jobs.cancelJobsForMissingBackends(); await waitFor(() => jobs.statuses().some((s) => s.backend === 'm' && s.status !== 'running')); @@ -525,13 +530,13 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { ); setSetting('storage.categories', JSON.stringify({ files: 'nas' })); const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); const jobs = new StorageJobsService(registry); for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000))); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1); // Simulate a config save: 'files' is rerouted to 'uploads-local' and the @@ -546,7 +551,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { ]), ); setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); - registry.reload(); + await registry.reload(); jobs.cancelJobsForMissingBackends(); const final = await waitTerminal(jobs, 'files'); @@ -554,9 +559,9 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { }); it('JOBS-022 cancels a running migration re-routed to a third, still-defined backend (route no longer matches `from`)', async () => { - const { storage, jobs, registry } = makeMigrationWorld(); // 'files' -> 'uploads-local'; 'dest-local' is the target + const { storage, jobs, registry } = await makeMigrationWorld(); // 'files' -> 'uploads-local'; 'dest-local' is the target for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000))); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1); // A config save re-routes 'files' to a THIRD backend — both the @@ -574,7 +579,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { ]), ); setSetting('storage.categories', JSON.stringify({ files: 'third-local' })); - registry.reload(); + await registry.reload(); jobs.cancelJobsForMissingBackends(); const final = await waitTerminal(jobs, 'files'); @@ -583,9 +588,9 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { }); it('JOBS-023 a save that does not touch the migrating category leaves it running', async () => { - const { storage, jobs, registry } = makeMigrationWorld(); + const { storage, jobs, registry } = await makeMigrationWorld(); for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000))); - jobs.startMigration('files', 'dest-local'); + await jobs.startMigration('files', 'dest-local'); await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1); // A save that touches an unrelated category only — 'files' route is untouched. diff --git a/server/tests/unit/nest/storage/storage-registry.service.test.ts b/server/tests/unit/nest/storage/storage-registry.service.test.ts index a6561dd96..2e8721ade 100644 --- a/server/tests/unit/nest/storage/storage-registry.service.test.ts +++ b/server/tests/unit/nest/storage/storage-registry.service.test.ts @@ -37,6 +37,7 @@ import { SEED_CONFIG_PATH, } from '../../../../src/nest/storage/storage-paths'; import { STORAGE_CATEGORIES } from '../../../../src/nest/storage/storage.types'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; const db = new DatabaseService(testDb); @@ -88,13 +89,13 @@ interface RegistryOpts { * merge-by-name). The helper seeds that override so unit runs never write * into the real server/uploads. */ -function makeRegistry(opts: RegistryOpts = {}) { +async function makeRegistry(opts: RegistryOpts = {}) { const uploadsRoot = opts.uploadsRoot ?? makeTmpDir(); setSetting('storage.backends', JSON.stringify([uploadsOverride(uploadsRoot), ...(opts.backends ?? [])])); if (opts.categories) setSetting('storage.categories', JSON.stringify(opts.categories)); const stub = makeEnvStub({ placePhotoDir: opts.placePhotoDir }); - const registry = new StorageRegistryService(db, stub.env, new StorageEventsService()); - if (opts.boot !== false) registry.onModuleInit(); + const registry = new StorageRegistryService(db, stub.env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + if (opts.boot !== false) await registry.onModuleInit(); return { registry, uploadsRoot, setUploadsRoot: (root: string) => rewriteUploadsOverride(root) }; } @@ -120,8 +121,8 @@ afterEach(() => { // ── defaults ────────────────────────────────────────────────────────────────── describe('StorageRegistryService defaults', () => { - it('resolves every category with the built-in defaults and no settings rows', () => { - const { registry, uploadsRoot } = makeRegistry(); + it('resolves every category with the built-in defaults and no settings rows', async () => { + const { registry, uploadsRoot } = await makeRegistry(); for (const category of STORAGE_CATEGORIES) { expect(registry.resolve(category)).toBeDefined(); @@ -145,9 +146,9 @@ describe('StorageRegistryService defaults', () => { expect(googlePhotos.keyPrefix).toBe('photos/google/'); }); - it('honors TREK_PLACE_PHOTO_DIR via the conditional third backend (mode B: bare keys)', () => { + it('honors TREK_PLACE_PHOTO_DIR via the conditional third backend (mode B: bare keys)', async () => { const photoDir = makeTmpDir(); - const { registry } = makeRegistry({ placePhotoDir: photoDir }); + const { registry } = await makeRegistry({ placePhotoDir: photoDir }); const googlePhotos = registry.resolve('photos-google'); expect(googlePhotos.backendName).toBe('place-photos-local'); @@ -159,8 +160,8 @@ describe('StorageRegistryService defaults', () => { expect(registry.resolve('photos-trek').keyPrefix).toBe('photos/trek/'); }); - it('creates roots, spools, category prefix dirs, and the global temp dir on load', () => { - const { uploadsRoot } = makeRegistry(); + it('creates roots, spools, category prefix dirs, and the global temp dir on load', async () => { + const { uploadsRoot } = await makeRegistry(); for (const sub of ['.tmp', 'files', 'journey', 'covers', 'avatars', 'places', 'photos', 'photos/trek', 'photos/google']) { expect(fs.statSync(path.join(uploadsRoot, sub)).isDirectory()).toBe(true); @@ -169,10 +170,10 @@ describe('StorageRegistryService defaults', () => { expect(fs.statSync(path.join(DEFAULT_BACKUPS_ROOT, '.tmp')).isDirectory()).toBe(true); }); - it('roots uploads-local at the computed default when no override row exists', () => { - const { registry } = makeRegistry({ boot: false }); + it('roots uploads-local at the computed default when no override row exists', async () => { + const { registry } = await makeRegistry({ boot: false }); testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run(); - registry.onModuleInit(); + await registry.onModuleInit(); const files = registry.resolve('files'); expect(files.backendName).toBe('uploads-local'); expect(files.driver.getLocalPath!('files/x.pdf')).toBe( @@ -180,17 +181,17 @@ describe('StorageRegistryService defaults', () => { ); }); - it('a settings row bearing a built-in name replaces it (first-class override)', () => { + it('a settings row bearing a built-in name replaces it (first-class override)', async () => { const relocated = makeTmpDir(); - const { registry } = makeRegistry({ uploadsRoot: relocated }); + const { registry } = await makeRegistry({ uploadsRoot: relocated }); expect(registry.resolve('files').driver.getLocalPath!('files/x.pdf')).toBe( path.join(fs.realpathSync(relocated), 'files/x.pdf'), ); }); - it('serves the legacy photos category from uploads-local without any config (ServedCategory seam)', () => { + it('serves the legacy photos category from uploads-local without any config (ServedCategory seam)', async () => { const relocated = makeTmpDir(); - const { registry } = makeRegistry({ uploadsRoot: relocated }); + const { registry } = await makeRegistry({ uploadsRoot: relocated }); const photos = registry.resolve('photos'); expect(photos.backendName).toBe('uploads-local'); expect(photos.keyPrefix).toBe('photos/'); @@ -200,8 +201,8 @@ describe('StorageRegistryService defaults', () => { ); }); - it('the admin snapshot exposes only the 8 configurable categories — photos is served, not configurable', () => { - const { registry } = makeRegistry(); + it('the admin snapshot exposes only the 8 configurable categories — photos is served, not configurable', async () => { + const { registry } = await makeRegistry(); const keys = Object.keys(registry.snapshot().categories); expect(keys).toHaveLength(8); expect(keys).not.toContain('photos'); @@ -211,24 +212,24 @@ describe('StorageRegistryService defaults', () => { // ── keyPrefixFor (extracted for the category-migration job) ─────────────────── describe('StorageRegistryService keyPrefixFor', () => { - it('returns the plain category prefix regardless of which backend is asked', () => { - const { registry } = makeRegistry(); + it('returns the plain category prefix regardless of which backend is asked', async () => { + const { registry } = await makeRegistry(); expect(registry.keyPrefixFor('files', 'uploads-local')).toBe('files/'); expect(registry.keyPrefixFor('files', 'some-other-backend')).toBe('files/'); expect(registry.keyPrefixFor('backups', 'backups-local')).toBe(''); expect(registry.keyPrefixFor('covers', 'anything')).toBe('covers/'); }); - it('photos-google is bare-key (mode A) ONLY on place-photos-local; every other backend gets the prefixed mode-B layout', () => { - const { registry } = makeRegistry(); + it('photos-google is bare-key (mode A) ONLY on place-photos-local; every other backend gets the prefixed mode-B layout', async () => { + const { registry } = await makeRegistry(); expect(registry.keyPrefixFor('photos-google', 'place-photos-local')).toBe(''); expect(registry.keyPrefixFor('photos-google', 'uploads-local')).toBe('photos/google/'); expect(registry.keyPrefixFor('photos-google', 'dest-local')).toBe('photos/google/'); }); - it('agrees with resolve() for the category\'s CURRENT backend — no drift between the two rules', () => { + it('agrees with resolve() for the category\'s CURRENT backend — no drift between the two rules', async () => { const photoDir = makeTmpDir(); - const { registry } = makeRegistry({ placePhotoDir: photoDir }); + const { registry } = await makeRegistry({ placePhotoDir: photoDir }); const resolved = registry.resolve('photos-google'); expect(registry.keyPrefixFor('photos-google', resolved.backendName)).toBe(resolved.keyPrefix); }); @@ -237,9 +238,9 @@ describe('StorageRegistryService keyPrefixFor', () => { // ── settings merge + validation ─────────────────────────────────────────────── describe('StorageRegistryService settings', () => { - it('merges app_settings backends/categories over the defaults (backup mirror)', () => { + it('merges app_settings backends/categories over the defaults (backup mirror)', async () => { const nasRoot = makeTmpDir(); - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [ { name: 'nas-backups', type: 'local', options: { root: nasRoot } }, { name: 'backup-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } }, @@ -254,9 +255,9 @@ describe('StorageRegistryService settings', () => { expect(registry.resolve('files').backendName).toBe('uploads-local'); }); - it('a mirror may serve any category — the backups-only rule is lifted (replicas-on-primary spec)', () => { + it('a mirror may serve any category — the backups-only rule is lifted (replicas-on-primary spec)', async () => { const extraRoot = makeTmpDir(); - const { registry, uploadsRoot } = makeRegistry({ + const { registry, uploadsRoot } = await makeRegistry({ backends: [ { name: 'extra-local', type: 'local', options: { root: extraRoot } }, { name: 'm', type: 'mirror', options: { primary: 'uploads-local', replicas: ['extra-local'] } }, @@ -292,18 +293,18 @@ describe('StorageRegistryService settings', () => { ['unknown category name', undefined, JSON.stringify({ 'not-a-category': 'uploads-local' })], ['retired photos category in settings', undefined, JSON.stringify({ photos: 'uploads-local' })], ['malformed JSON', 'not json at all', undefined], - ])('falls back to built-in defaults at boot on invalid settings: %s', (_label, backendsRow, categoriesRow) => { - const { registry } = makeRegistry({ boot: false }); + ])('falls back to built-in defaults at boot on invalid settings: %s', async (_label, backendsRow, categoriesRow) => { + const { registry } = await makeRegistry({ boot: false }); if (backendsRow !== undefined) setSetting('storage.backends', backendsRow); if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow); - registry.onModuleInit(); + await registry.onModuleInit(); expect(registry.resolve('files').backendName).toBe('uploads-local'); expect(registry.resolve('backups').backendName).toBe('backups-local'); }); - it('lastLoadError() is null on a clean boot', () => { - const { registry } = makeRegistry(); + it('lastLoadError() is null on a clean boot', async () => { + const { registry } = await makeRegistry(); expect(registry.lastLoadError()).toBeNull(); }); @@ -312,12 +313,12 @@ describe('StorageRegistryService settings', () => { ['malformed JSON', 'not json at all', undefined], ])( 'lastLoadError() records the exact failure at boot and stays set (silent-fallback audit minor): %s', - (_label, backendsRow, categoriesRow) => { + async (_label, backendsRow, categoriesRow) => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { registry } = makeRegistry({ boot: false }); + const { registry } = await makeRegistry({ boot: false }); if (backendsRow !== undefined) setSetting('storage.backends', backendsRow); if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow); - registry.onModuleInit(); + await registry.onModuleInit(); expect(registry.lastLoadError()).not.toBeNull(); // Still resolves on built-in defaults — the fallback itself is unchanged, @@ -326,17 +327,17 @@ describe('StorageRegistryService settings', () => { }, ); - it('a malformed row never leaks a secret-looking token into lastLoadError() (admin-facing configError)', () => { + it('a malformed row never leaks a secret-looking token into lastLoadError() (admin-facing configError)', async () => { // JSON.parse's own SyntaxError can echo a snippet of the raw input around // the failure position (Node 24 V8) — an unquoted value like this is // exactly the shape a corrupted/hand-edited secretAccessKey row takes. // lastLoadError() must never carry that snippet through to the // admin-facing configError banner. vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { registry } = makeRegistry({ boot: false }); + const { registry } = await makeRegistry({ boot: false }); const secretToken = 'AKIA_SUPER_SECRET_TOKEN_1234567890'; setSetting('storage.backends', `{"secretAccessKey": ${secretToken}_undefined_broken}`); - registry.onModuleInit(); + await registry.onModuleInit(); const err = registry.lastLoadError(); expect(err).not.toBeNull(); @@ -345,21 +346,21 @@ describe('StorageRegistryService settings', () => { expect(err).toContain("'storage.backends' contains malformed JSON"); }); - it('lastLoadError() clears on the next successful load/reload', () => { + it('lastLoadError() clears on the next successful load/reload', async () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { registry } = makeRegistry({ boot: false }); + const { registry } = await makeRegistry({ boot: false }); setSetting('storage.categories', 'garbage {'); - registry.onModuleInit(); + await registry.onModuleInit(); expect(registry.lastLoadError()).not.toBeNull(); setSetting('storage.categories', '{}'); - registry.reload(); + await registry.reload(); expect(registry.lastLoadError()).toBeNull(); }); - it('keeps the last-good config (not defaults) when a reload() sees invalid settings', () => { + it('keeps the last-good config (not defaults) when a reload() sees invalid settings', async () => { const nasRoot = makeTmpDir(); - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [ { name: 'nas-backups', type: 'local', options: { root: nasRoot } }, { name: 'backup-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } }, @@ -369,7 +370,7 @@ describe('StorageRegistryService settings', () => { expect(registry.resolve('backups').backendName).toBe('backup-mirror'); setSetting('storage.categories', 'garbage {'); - registry.reload(); + await registry.reload(); // last-good, i.e. the mirror config — NOT the built-in defaults expect(registry.resolve('backups').backendName).toBe('backup-mirror'); @@ -382,12 +383,12 @@ describe('StorageRegistryService settings', () => { describe('StorageRegistryService reload', () => { it('swaps the map atomically: new resolves see the new instance, held refs stay usable', async () => { - const { registry, setUploadsRoot } = makeRegistry(); + const { registry, setUploadsRoot } = await makeRegistry(); const before = registry.resolve('files'); await before.driver.put('files/pre-reload.bin', Readable.from('old root')); setUploadsRoot(makeTmpDir()); - registry.reload(); + await registry.reload(); const after = registry.resolve('files'); expect(after.driver).not.toBe(before.driver); @@ -400,7 +401,7 @@ describe('StorageRegistryService reload', () => { expect(Buffer.concat(chunks).toString()).toBe('old root'); }); - it('cleans spool leftovers at boot only, never on reload()', () => { + it('cleans spool leftovers at boot only, never on reload()', async () => { const uploadsRoot = makeTmpDir(); const stray = path.join(uploadsRoot, '.tmp', 'in-flight.part'); @@ -410,11 +411,11 @@ describe('StorageRegistryService reload', () => { // process may be spooling into the same tree — see LocalDriver.init). const old = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(stray, old, old); - const { registry } = makeRegistry({ uploadsRoot }); + const { registry } = await makeRegistry({ uploadsRoot }); expect(fs.existsSync(stray)).toBe(false); // boot reclaimed it fs.writeFileSync(stray, 'in-flight upload'); - registry.reload(); + await registry.reload(); expect(fs.existsSync(stray)).toBe(true); // reload must not touch it }); }); @@ -422,13 +423,13 @@ describe('StorageRegistryService reload', () => { // ── assignCategory ──────────────────────────────────────────────────────────── describe('StorageRegistryService assignCategory', () => { - it('REG-ASSIGN-001 throws on an unknown backend and persists nothing (belt-and-braces alongside the migration job\'s own guard)', () => { - const { registry } = makeRegistry(); + it('REG-ASSIGN-001 throws on an unknown backend and persists nothing (belt-and-braces alongside the migration job\'s own guard)', async () => { + const { registry } = await makeRegistry(); const before = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as | { value: string } | undefined; - expect(() => registry.assignCategory('files', 'ghost-backend')).toThrow( + await expect(registry.assignCategory('files', 'ghost-backend')).rejects.toThrow( "cannot assign 'files' to unknown backend 'ghost-backend'", ); @@ -439,10 +440,10 @@ describe('StorageRegistryService assignCategory', () => { expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); // unchanged }); - it('REG-ASSIGN-002 assigns and persists when the backend exists', () => { - const { registry } = makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] }); + it('REG-ASSIGN-002 assigns and persists when the backend exists', async () => { + const { registry } = await makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] }); - registry.assignCategory('files', 'dest-local'); + await registry.assignCategory('files', 'dest-local'); expect(registry.snapshot().categories.files.backend).toBe('dest-local'); const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as { @@ -451,15 +452,15 @@ describe('StorageRegistryService assignCategory', () => { expect((JSON.parse(row.value) as Record).files).toBe('dest-local'); }); - it('REG-ASSIGN-003 bumps the shared optimistic-concurrency version counter by exactly one, in the same write (audit #7)', () => { - const { registry } = makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] }); - expect(registry.currentConfigVersion()).toBe(0); + it('REG-ASSIGN-003 bumps the shared optimistic-concurrency version counter by exactly one, in the same write (audit #7)', async () => { + const { registry } = await makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] }); + expect(await registry.currentConfigVersion()).toBe(0); - registry.assignCategory('files', 'dest-local'); - expect(registry.currentConfigVersion()).toBe(1); + await registry.assignCategory('files', 'dest-local'); + expect(await registry.currentConfigVersion()).toBe(1); - registry.assignCategory('journey', 'dest-local'); - expect(registry.currentConfigVersion()).toBe(2); + await registry.assignCategory('journey', 'dest-local'); + expect(await registry.currentConfigVersion()).toBe(2); const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.config_version'").get() as | { value: string } @@ -467,14 +468,14 @@ describe('StorageRegistryService assignCategory', () => { expect(row?.value).toBe('2'); }); - it('REG-ASSIGN-004 an unknown-backend refusal bumps nothing — the version write shares the categories transaction', () => { - const { registry } = makeRegistry(); - expect(() => registry.assignCategory('files', 'ghost-backend')).toThrow(); - expect(registry.currentConfigVersion()).toBe(0); + it('REG-ASSIGN-004 an unknown-backend refusal bumps nothing — the version write shares the categories transaction', async () => { + const { registry } = await makeRegistry(); + await expect(registry.assignCategory('files', 'ghost-backend')).rejects.toThrow(); + expect(await registry.currentConfigVersion()).toBe(0); }); - it('REG-ASSIGN-005 refuses the migration flip onto a backend that is currently a mirror replica (validateConfig, other direction)', () => { - const { registry } = makeRegistry({ + it('REG-ASSIGN-005 refuses the migration flip onto a backend that is currently a mirror replica (validateConfig, other direction)', async () => { + const { registry } = await makeRegistry({ backends: [ { name: 'nas-backups', type: 'local', options: { root: makeTmpDir() } }, { name: 'backups-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } }, @@ -482,15 +483,15 @@ describe('StorageRegistryService assignCategory', () => { categories: { backups: 'backups-mirror' }, }); - expect(() => registry.assignCategory('files', 'nas-backups')).toThrow( + await expect(registry.assignCategory('files', 'nas-backups')).rejects.toThrow( /cannot assign 'files' to 'nas-backups' — 'nas-backups' is a mirror replica of 'backups-mirror'/, ); expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); // unchanged - expect(registry.currentConfigVersion()).toBe(0); // nothing written + expect(await registry.currentConfigVersion()).toBe(0); // nothing written }); - it('REG-ASSIGN-006 refuses the flip onto a MIRROR whose primary is a replica of another mirror', () => { - const { registry } = makeRegistry({ + it('REG-ASSIGN-006 refuses the flip onto a MIRROR whose primary is a replica of another mirror', async () => { + const { registry } = await makeRegistry({ backends: [ { name: 'nas', type: 'local', options: { root: makeTmpDir() } }, { name: 'extra', type: 'local', options: { root: makeTmpDir() } }, @@ -500,18 +501,18 @@ describe('StorageRegistryService assignCategory', () => { categories: { backups: 'backups-mirror' }, }); - expect(() => registry.assignCategory('files', 'nas-mirror')).toThrow( + await expect(registry.assignCategory('files', 'nas-mirror')).rejects.toThrow( /cannot assign 'files' to 'nas-mirror' — 'nas' is a mirror replica of 'backups-mirror'/, ); - expect(registry.currentConfigVersion()).toBe(0); + expect(await registry.currentConfigVersion()).toBe(0); }); }); // ── replica-failure health ──────────────────────────────────────────────────── describe('StorageRegistryService replica health', () => { - it('keeps a bounded ring of replica failures (last 50)', () => { - const { registry } = makeRegistry(); + it('keeps a bounded ring of replica failures (last 50)', async () => { + const { registry } = await makeRegistry(); for (let i = 0; i < 60; i++) { registry.recordReplicaFailure({ backend: 'nas-backups', @@ -542,8 +543,8 @@ describe('settings-declared s3 backends', () => { timeoutMs: 30000, }; - it('accepts a settings s3 backend and assigns it to a category', () => { - const { registry } = makeRegistry({ + it('accepts a settings s3 backend and assigns it to a category', async () => { + const { registry } = await makeRegistry({ backends: [{ name: 'off-box', type: 's3', options: s3Options }], categories: { covers: 'off-box' }, }); @@ -553,26 +554,26 @@ describe('settings-declared s3 backends', () => { expect(resolved.keyPrefix).toBe('covers/'); }); - it('decrypts an enc:v1: secretAccessKey at build (decrypt-at-build)', () => { - const { registry } = makeRegistry({ + it('decrypts an enc:v1: secretAccessKey at build (decrypt-at-build)', async () => { + const { registry } = await makeRegistry({ backends: [{ name: 'off-box', type: 's3', options: { ...s3Options, secretAccessKey: encrypt_api_key('sk-secret') } }], categories: { backups: 'off-box' }, }); expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver); }); - it('applies the shared-schema defaults for omitted optional fields', () => { + it('applies the shared-schema defaults for omitted optional fields', async () => { const { endpoint, bucket, accessKeyId, secretAccessKey } = s3Options; - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [{ name: 'off-box', type: 's3', options: { endpoint, bucket, accessKeyId, secretAccessKey } }], categories: { backups: 'off-box' }, }); expect(registry.resolve('backups').backendName).toBe('off-box'); }); - it('serves as a mirror replica (local primary + settings s3 replica on backups)', () => { + it('serves as a mirror replica (local primary + settings s3 replica on backups)', async () => { const nasRoot = makeTmpDir(); - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [ { name: 'off-box', type: 's3', options: s3Options }, { name: 'nas-backups', type: 'local', options: { root: nasRoot } }, @@ -584,9 +585,9 @@ describe('settings-declared s3 backends', () => { expect(registry.resolve('backups').driver).toBeInstanceOf(MirrorDriver); }); - it('falls back with the pinned message when s3 options fail the shared schema', () => { + it('falls back with the pinned message when s3 options fail the shared schema', async () => { const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [{ name: 'off-box', type: 's3', options: { ...s3Options, endpoint: 'not a url' } }], }); expect(registry.resolve('backups').backendName).toBe('backups-local'); @@ -594,9 +595,9 @@ describe('settings-declared s3 backends', () => { expect(logged).toContain("s3 backend 'off-box' has invalid options — endpoint:"); }); - it('falls back with the pinned message when the ciphertext cannot be decrypted', () => { + it('falls back with the pinned message when the ciphertext cannot be decrypted', async () => { const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [{ name: 'off-box', type: 's3', options: { ...s3Options, secretAccessKey: 'enc:v1:AAAA' } }], categories: { backups: 'off-box' }, }); @@ -609,8 +610,8 @@ describe('settings-declared s3 backends', () => { // ── preview() — admin writes never take the silent fallback ────────────────── describe('preview()', () => { - it('throws the exact registry error for an invalid candidate and mutates nothing', () => { - const { registry } = makeRegistry(); + it('throws the exact registry error for an invalid candidate and mutates nothing', async () => { + const { registry } = await makeRegistry(); const before = registry.resolve('backups').driver; expect(() => registry.preview({ backends: [], categories: { backups: 'nope' } }), @@ -618,9 +619,9 @@ describe('preview()', () => { expect(registry.resolve('backups').driver).toBe(before); // same instance — state untouched }); - it('accepts a valid candidate without changing resolution', () => { + it('accepts a valid candidate without changing resolution', async () => { const nasRoot = makeTmpDir(); - const { registry } = makeRegistry(); + const { registry } = await makeRegistry(); const before = registry.resolve('backups').driver; registry.preview({ backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }], @@ -640,8 +641,8 @@ describe('preview()', () => { // sweep itself is correct; the fix is that no config can express the setup. describe('StorageRegistryService shared-replica refusals', () => { - it('REG-SHARED-001 refuses the config that would let a backups sync sweep another category\'s objects (uploads-local as a backups-mirror replica)', () => { - const { registry } = makeRegistry(); + it('REG-SHARED-001 refuses the config that would let a backups sync sweep another category\'s objects (uploads-local as a backups-mirror replica)', async () => { + const { registry } = await makeRegistry(); expect(() => registry.preview({ backends: [ @@ -653,8 +654,8 @@ describe('StorageRegistryService shared-replica refusals', () => { ).toThrow(/backend 'uploads-local' is a mirror replica of 'backups-mirror' and also serves category/); }); - it('REG-SHARED-002 refuses a replica that serves a category as ANOTHER mirror\'s primary', () => { - const { registry } = makeRegistry(); + it('REG-SHARED-002 refuses a replica that serves a category as ANOTHER mirror\'s primary', async () => { + const { registry } = await makeRegistry(); expect(() => registry.preview({ backends: [ @@ -668,8 +669,8 @@ describe('StorageRegistryService shared-replica refusals', () => { ).toThrow(/backend 'nas' is a mirror replica of 'backups-mirror' and also serves category 'files'/); }); - it('REG-SHARED-003 refuses one backend replicating two mirrors whose swept prefixes overlap (\'\' overlaps everything)', () => { - const { registry } = makeRegistry(); + it('REG-SHARED-003 refuses one backend replicating two mirrors whose swept prefixes overlap (\'\' overlaps everything)', async () => { + const { registry } = await makeRegistry(); expect(() => registry.preview({ backends: [ @@ -684,8 +685,8 @@ describe('StorageRegistryService shared-replica refusals', () => { ).toThrow(/backend 'nas' replicates both 'm-backups' and 'm-files', whose swept key prefixes overlap/); }); - it('REG-SHARED-004 accepts one backend replicating two mirrors with DISJOINT swept prefixes', () => { - const { registry } = makeRegistry(); + it('REG-SHARED-004 accepts one backend replicating two mirrors with DISJOINT swept prefixes', async () => { + const { registry } = await makeRegistry(); expect(() => registry.preview({ backends: [ @@ -700,8 +701,8 @@ describe('StorageRegistryService shared-replica refusals', () => { ).not.toThrow(); }); - it('REG-SHARED-005 still accepts the ordinary shape: a dedicated, unassigned replica', () => { - const { registry } = makeRegistry(); + it('REG-SHARED-005 still accepts the ordinary shape: a dedicated, unassigned replica', async () => { + const { registry } = await makeRegistry(); expect(() => registry.preview({ backends: [ @@ -725,9 +726,9 @@ describe('seed-once storage-config.json import', () => { } /** A registry with NO storage.* rows (makeRegistry seeds an override row, so build raw). */ - function makeUnseededRegistry() { + async function makeUnseededRegistry() { const stub = makeEnvStub({}); - return new StorageRegistryService(db, stub.env, new StorageEventsService()); + return new StorageRegistryService(db, stub.env, new StorageEventsService(), await createTestUnitOfWork(testDb)); } function readRow(key: string): string | undefined { @@ -741,7 +742,7 @@ describe('seed-once storage-config.json import', () => { fs.rmSync(SEED_CONFIG_PATH, { force: true }); }); - it('SEED-001 imports a valid file when no rows exist: encrypted rows, live drivers, pinned log', () => { + it('SEED-001 imports a valid file when no rows exist: encrypted rows, live drivers, pinned log', async () => { const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); const root = nasRoot(); writeSeed( @@ -762,8 +763,8 @@ describe('seed-once storage-config.json import', () => { categories: { backups: 'off-box' }, }), ); - const registry = makeUnseededRegistry(); - registry.onModuleInit(); + const registry = await makeUnseededRegistry(); + await registry.onModuleInit(); expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver); const storedBackends = JSON.parse(readRow('storage.backends')!) as Array<{ @@ -778,41 +779,41 @@ describe('seed-once storage-config.json import', () => { expect(logged).toContain('the file is now ignored; manage storage in the admin UI'); }); - it('SEED-002 rows exist → file not read, pinned ignore log, rows unchanged', () => { + it('SEED-002 rows exist → file not read, pinned ignore log, rows unchanged', async () => { const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); writeSeed('{ this would explode if parsed'); - const { registry } = makeRegistry(); // helper writes the uploads override row + const { registry } = await makeRegistry(); // helper writes the uploads override row expect(registry.resolve('files').backendName).toBe('uploads-local'); const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(logged).toContain('storage config rows exist — ignoring'); }); - it('SEED-003 no rows, no file → boots on built-in defaults (no seed side effects)', () => { - const registry = makeUnseededRegistry(); - registry.onModuleInit(); + it('SEED-003 no rows, no file → boots on built-in defaults (no seed side effects)', async () => { + const registry = await makeUnseededRegistry(); + await registry.onModuleInit(); expect(registry.resolve('backups').backendName).toBe('backups-local'); expect(readRow('storage.backends')).toBeUndefined(); }); - it('SEED-004 unparseable JSON aborts boot with the exact error', () => { + it('SEED-004 unparseable JSON aborts boot with the exact error', async () => { writeSeed('not json at all'); - const registry = makeUnseededRegistry(); - expect(() => registry.onModuleInit()).toThrow(/invalid storage seed file .*storage-config\.json: /); + const registry = await makeUnseededRegistry(); + await expect(registry.onModuleInit()).rejects.toThrow(/invalid storage seed file .*storage-config\.json: /); }); - it('SEED-005 schema violation aborts boot — including the reserved readOnly key', () => { + it('SEED-005 schema violation aborts boot — including the reserved readOnly key', async () => { writeSeed(JSON.stringify({ backends: [], categories: {}, readOnly: true })); - expect(() => makeUnseededRegistry().onModuleInit()).toThrow(/invalid storage seed file/); + await expect((await makeUnseededRegistry()).onModuleInit()).rejects.toThrow(/invalid storage seed file/); }); - it('SEED-006 semantic violation aborts boot with the registry error verbatim', () => { + it('SEED-006 semantic violation aborts boot with the registry error verbatim', async () => { writeSeed(JSON.stringify({ backends: [], categories: { backups: 'nope' } })); - expect(() => makeUnseededRegistry().onModuleInit()).toThrow( + await expect((await makeUnseededRegistry()).onModuleInit()).rejects.toThrow( /category 'backups' maps to unknown backend 'nope'/, ); }); - it('SEED-007 a plaintext-secret seed imports without an explicit ENCRYPTION_KEY and encrypts at rest', () => { + it('SEED-007 a plaintext-secret seed imports without an explicit ENCRYPTION_KEY and encrypts at rest', async () => { writeSeed( JSON.stringify({ backends: [ @@ -830,8 +831,8 @@ describe('seed-once storage-config.json import', () => { categories: {}, }), ); - const registry = makeUnseededRegistry(); - registry.onModuleInit(); + const registry = await makeUnseededRegistry(); + await registry.onModuleInit(); // No key-presence gate: the implicit key covers encryption when // ENCRYPTION_KEY is unset, and the plaintext never persists. const row = readRow('storage.backends')!; @@ -839,7 +840,7 @@ describe('seed-once storage-config.json import', () => { expect(row).toContain('enc:v1:'); }); - it('SEED-008 a mask sentinel in the seed file aborts boot', () => { + it('SEED-008 a mask sentinel in the seed file aborts boot', async () => { writeSeed( JSON.stringify({ backends: [ @@ -857,23 +858,23 @@ describe('seed-once storage-config.json import', () => { categories: {}, }), ); - expect(() => makeUnseededRegistry().onModuleInit()).toThrow(/mask/); + await expect((await makeUnseededRegistry()).onModuleInit()).rejects.toThrow(/mask/); }); - it('SEED-009 a second boot after a successful seed ignores the file (seed-once)', () => { + it('SEED-009 a second boot after a successful seed ignores the file (seed-once)', async () => { const root = nasRoot(); writeSeed(JSON.stringify({ backends: [{ name: 'nas', type: 'local', options: { root } }], categories: { backups: 'nas' } })); - makeUnseededRegistry().onModuleInit(); + await (await makeUnseededRegistry()).onModuleInit(); const firstRow = readRow('storage.backends'); writeSeed(JSON.stringify({ backends: [], categories: { backups: 'other' } })); // would fail preview if read - const second = makeUnseededRegistry(); - second.onModuleInit(); // must not throw — rows exist, file ignored + const second = await makeUnseededRegistry(); + await second.onModuleInit(); // must not throw — rows exist, file ignored expect(readRow('storage.backends')).toBe(firstRow); expect(second.resolve('backups').backendName).toBe('nas'); }); - it('SEED-010 an all-encrypted seed imports and keeps its ciphertext byte-for-byte', () => { + it('SEED-010 an all-encrypted seed imports and keeps its ciphertext byte-for-byte', async () => { const cipher = encrypt_api_key('sk-seed'); writeSeed( JSON.stringify({ @@ -892,8 +893,8 @@ describe('seed-once storage-config.json import', () => { categories: { backups: 'off-box' }, }), ); - const registry = makeUnseededRegistry(); - registry.onModuleInit(); + const registry = await makeUnseededRegistry(); + await registry.onModuleInit(); // enc:v1: values pass through the idempotent encrypt — the ciphertext // must persist byte-for-byte. expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver); @@ -904,10 +905,10 @@ describe('seed-once storage-config.json import', () => { // ── snapshot() — effective world with provenance ────────────────────────────── describe('snapshot()', () => { - it('reports built-ins, env backends, and settings entries with their sources', () => { + it('reports built-ins, env backends, and settings entries with their sources', async () => { const photoDir = makeTmpDir(); const nasRoot = makeTmpDir(); - const { registry, uploadsRoot } = makeRegistry({ + const { registry, uploadsRoot } = await makeRegistry({ placePhotoDir: photoDir, backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }], categories: { backups: 'nas-backups' }, @@ -927,10 +928,10 @@ describe('snapshot()', () => { expect(Object.keys(snap.categories).sort()).toEqual([...STORAGE_CATEGORIES].sort()); }); - it('reports pure built-in defaults when no settings rows exist', () => { - const { registry } = makeRegistry({ boot: false }); + it('reports pure built-in defaults when no settings rows exist', async () => { + const { registry } = await makeRegistry({ boot: false }); testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run(); - registry.onModuleInit(); + await registry.onModuleInit(); const snap = registry.snapshot(); expect(snap.backends.map((b) => [b.name, b.source])).toEqual([ ['uploads-local', 'built-in'], @@ -938,9 +939,9 @@ describe('snapshot()', () => { ]); }); - it('keeps encrypted secrets encrypted in the snapshot (never plaintext)', () => { + it('keeps encrypted secrets encrypted in the snapshot (never plaintext)', async () => { const cipher = encrypt_api_key('sk-secret'); - const { registry } = makeRegistry({ + const { registry } = await makeRegistry({ backends: [ { name: 'off-box', @@ -960,12 +961,12 @@ describe('snapshot()', () => { expect(offBox.options).toMatchObject({ region: 'us-east-1', retries: 1 }); // schema defaults visible }); - it('shows the last-good world after an invalid reload (snapshot = live state)', () => { + it('shows the last-good world after an invalid reload (snapshot = live state)', async () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - const { registry } = makeRegistry(); + const { registry } = await makeRegistry(); const before = registry.snapshot(); setSetting('storage.categories', 'garbage {'); - registry.reload(); + await registry.reload(); expect(registry.snapshot()).toEqual(before); }); }); diff --git a/server/tests/unit/nest/storage/storage-stats.service.test.ts b/server/tests/unit/nest/storage/storage-stats.service.test.ts index a79eb3047..12b808e2d 100644 --- a/server/tests/unit/nest/storage/storage-stats.service.test.ts +++ b/server/tests/unit/nest/storage/storage-stats.service.test.ts @@ -22,6 +22,7 @@ import { StorageEventsService } from '../../../../src/nest/storage/storage-event import { StorageRegistryService } from '../../../../src/nest/storage/storage-registry.service'; import { StorageService } from '../../../../src/nest/storage/storage.service'; import { StatsBusyError, StorageStatsService } from '../../../../src/nest/storage/storage-stats.service'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; const db = new DatabaseService(testDb); @@ -47,7 +48,7 @@ afterEach(() => { while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); }); -function makeWorld() { +async function makeWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); setSetting( @@ -58,8 +59,8 @@ function makeWorld() { ]), ); const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService; - const registry = new StorageRegistryService(db, env, new StorageEventsService()); - registry.onModuleInit(); + const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb)); + await registry.onModuleInit(); const storage = new StorageService(registry); const stats = new StorageStatsService(storage, db); return { storage, stats, uploadsRoot }; @@ -67,7 +68,7 @@ function makeWorld() { describe('StorageStatsService', () => { it('STATS-001 sums objects and bytes per category, legacy photos separate, and persists with computedAt', async () => { - const { storage, stats } = makeWorld(); + const { storage, stats } = await makeWorld(); await storage.put('files', 'a.pdf', Readable.from('12345')); // 5 bytes await storage.put('files', 'b.pdf', Readable.from('123')); // 3 bytes await storage.put('covers', 'c.jpg', Readable.from('1234567')); // 7 bytes @@ -79,13 +80,13 @@ describe('StorageStatsService', () => { expect(usage.legacyPhotos).toEqual({ objects: 1, bytes: 2 }); expect(usage.computedAt).toBeGreaterThan(0); // Persisted round-trip: - expect(stats.readUsage()).toEqual(usage); + expect(await stats.readUsage()).toEqual(usage); const raw = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.usage'").get() as { value: string }; expect(JSON.parse(raw.value)).toEqual(usage); }); it('STATS-002 photos-google/photos-trek nested content is NOT double-counted into legacy photos', async () => { - const { storage, stats } = makeWorld(); + const { storage, stats } = await makeWorld(); await storage.put('photos-google', 'g.jpg', Readable.from('gggg')); const usage = await stats.scan(); expect(usage.categories['photos-google']).toEqual({ objects: 1, bytes: 4 }); @@ -93,17 +94,17 @@ describe('StorageStatsService', () => { }); it('STATS-003 concurrent scans throw StatsBusyError', async () => { - const { storage, stats } = makeWorld(); + const { storage, stats } = await makeWorld(); for (let i = 0; i < 30; i++) await storage.put('files', `f${i}.bin`, Readable.from('x'.repeat(500))); const first = stats.scan(); await expect(stats.scan()).rejects.toThrow(StatsBusyError); await first; }); - it('STATS-004 readUsage returns null on absent or unparseable rows', () => { - const { stats } = makeWorld(); - expect(stats.readUsage()).toBeNull(); + it('STATS-004 readUsage returns null on absent or unparseable rows', async () => { + const { stats } = await makeWorld(); + expect(await stats.readUsage()).toBeNull(); setSetting('storage.usage', 'not json'); - expect(stats.readUsage()).toBeNull(); + expect(await stats.readUsage()).toBeNull(); }); }); diff --git a/server/tests/unit/nest/storage/storage-usage-scan.job.test.ts b/server/tests/unit/nest/storage/storage-usage-scan.job.test.ts index 78df97c3f..65c7d9c30 100644 --- a/server/tests/unit/nest/storage/storage-usage-scan.job.test.ts +++ b/server/tests/unit/nest/storage/storage-usage-scan.job.test.ts @@ -25,17 +25,17 @@ function statsStub(): StorageStatsService { beforeEach(() => vi.restoreAllMocks()); describe('StorageUsageScanJob', () => { - it('SCAN-001 registers the nightly 04:15 cron when the registrar gate is open', () => { + it('SCAN-001 registers the nightly 04:15 cron when the registrar gate is open', async () => { const registrar = registrarStub(); const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, statsStub()); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('storage-usage-scan', '15 4 * * *', expect.any(Function)); }); - it('SCAN-002 does not register under the test gate (registrar.isEnabled() false)', () => { + it('SCAN-002 does not register under the test gate (registrar.isEnabled() false)', async () => { const registrar = registrarStub(false); const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, statsStub()); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).not.toHaveBeenCalled(); }); @@ -43,7 +43,7 @@ describe('StorageUsageScanJob', () => { const registrar = registrarStub(); const stats = statsStub(); const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, stats); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); const onTick = registrar.register.mock.calls[0]![2] as () => void; onTick(); await vi.waitFor(() => expect(stats.scan).toHaveBeenCalledTimes(1)); diff --git a/server/tests/unit/nest/todo.controller.test.ts b/server/tests/unit/nest/todo.controller.test.ts index 621d19b04..7ae9c3d6c 100644 --- a/server/tests/unit/nest/todo.controller.test.ts +++ b/server/tests/unit/nest/todo.controller.test.ts @@ -16,9 +16,9 @@ function makeService(overrides: Partial = {}): TodoService { } as unknown as TodoService; } -function thrown(fn: () => unknown): { status: number; body: unknown } { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { try { - fn(); + await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -31,9 +31,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { // trip-access.guard.test.ts with the check itself. describe('TodoController (parity with the legacy /api/trips/:tripId/todo route)', () => { - it('GET / returns items', () => { - const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new TodoController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); + it('GET / returns items', async () => { + const svc = makeService({ listItems: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial); + expect(await new TodoController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); }); describe('POST /', () => { @@ -43,75 +43,75 @@ describe('TodoController (parity with the legacy /api/trips/:tripId/todo route)' // bypass parameter pipes, so that path is covered by the e2e suite and // the schema spec in @trek/shared. - it('creates and broadcasts', () => { - const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Pack' }); + it('creates and broadcasts', async () => { + const createItem = vi.fn().mockResolvedValue({ id: 9, name: 'Pack' }); const broadcast = vi.fn(); const svc = makeService({ createItem, broadcast } as Partial); - expect(new TodoController(svc).create(user, '5', { name: 'Pack', priority: 2 }, 'sock')).toEqual({ item: { id: 9, name: 'Pack' } }); + expect(await new TodoController(svc).create(user, '5', { name: 'Pack', priority: 2 }, 'sock')).toEqual({ item: { id: 9, name: 'Pack' } }); expect(broadcast).toHaveBeenCalledWith('5', 'todo:created', { item: { id: 9, name: 'Pack' } }, 'sock'); }); }); describe('PUT /:id', () => { - it('404 when item missing', () => { - const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new TodoController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ + it('404 when item missing', async () => { + const svc = makeService({ updateItem: vi.fn().mockResolvedValue(null) } as Partial); + expect(await thrown(() => new TodoController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Item not found' }, }); }); - it('updates, forwards changed keys, normalizes checked to 0/1, broadcasts', () => { - const updateItem = vi.fn().mockReturnValue({ id: 9 }); + it('updates, forwards changed keys, normalizes checked to 0/1, broadcasts', async () => { + const updateItem = vi.fn().mockResolvedValue({ id: 9 }); const broadcast = vi.fn(); const svc = makeService({ updateItem, broadcast } as Partial); - new TodoController(svc).update(user, '5', '9', { checked: true }, 'sock'); + await new TodoController(svc).update(user, '5', '9', { checked: true }, 'sock'); expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ checked: 1 }), ['checked']); expect(broadcast).toHaveBeenCalledWith('5', 'todo:updated', { item: { id: 9 } }, 'sock'); }); - it('normalizes checked false to 0', () => { - const updateItem = vi.fn().mockReturnValue({ id: 9 }); + it('normalizes checked false to 0', async () => { + const updateItem = vi.fn().mockResolvedValue({ id: 9 }); const svc = makeService({ updateItem, broadcast: vi.fn() } as Partial); - new TodoController(svc).update(user, '5', '9', { checked: false }); + await new TodoController(svc).update(user, '5', '9', { checked: false }); expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ checked: 0 }), ['checked']); }); }); describe('DELETE /:id', () => { - it('404 when item missing', () => { - const svc = makeService({ deleteItem: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new TodoController(svc).remove(user, '5', '9'))).toEqual({ + it('404 when item missing', async () => { + const svc = makeService({ deleteItem: vi.fn().mockResolvedValue(false) } as Partial); + expect(await thrown(() => new TodoController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' }, }); }); - it('deletes and broadcasts', () => { - const deleteItem = vi.fn().mockReturnValue(true); + it('deletes and broadcasts', async () => { + const deleteItem = vi.fn().mockResolvedValue(true); const broadcast = vi.fn(); const svc = makeService({ deleteItem, broadcast } as Partial); - expect(new TodoController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true }); + expect(await new TodoController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'todo:deleted', { itemId: 9 }, 'sock'); }); }); - it('PUT /reorder succeeds with permission', () => { - const reorderItems = vi.fn(); + it('PUT /reorder succeeds with permission', async () => { + const reorderItems = vi.fn().mockResolvedValue(undefined); const svc = makeService({ reorderItems } as Partial); - expect(new TodoController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); + expect(await new TodoController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]); }); describe('category assignees', () => { - it('GET returns assignees', () => { - const svc = makeService({ getCategoryAssignees: vi.fn().mockReturnValue([{ user_id: 2 }]) } as Partial); - expect(new TodoController(svc).categoryAssignees(user, '5')).toEqual({ assignees: [{ user_id: 2 }] }); + it('GET returns assignees', async () => { + const svc = makeService({ getCategoryAssignees: vi.fn().mockResolvedValue([{ user_id: 2 }]) } as Partial); + expect(await new TodoController(svc).categoryAssignees(user, '5')).toEqual({ assignees: [{ user_id: 2 }] }); }); - it('PUT updates, decodes the category and broadcasts', () => { - const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]); + it('PUT updates, decodes the category and broadcasts', async () => { + const updateCategoryAssignees = vi.fn().mockResolvedValue([{ user_id: 2 }]); const broadcast = vi.fn(); const svc = makeService({ updateCategoryAssignees, broadcast } as Partial); - new TodoController(svc).updateCategoryAssignees(user, '5', 'To%20Buy', { user_ids: [2] }, 'sock'); + await new TodoController(svc).updateCategoryAssignees(user, '5', 'To%20Buy', { user_ids: [2] }, 'sock'); expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'To Buy', [2]); expect(broadcast).toHaveBeenCalledWith('5', 'todo:assignees', { category: 'To Buy', assignees: [{ user_id: 2 }] }, 'sock'); }); diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index ef86f1c28..233f67883 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -53,7 +53,8 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; let svc: TodoService; beforeAll(async () => { - svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService()); + const uow = await createTestUnitOfWork(testDb); + svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), uow), new RealtimeService(), uow); }); beforeAll(() => { @@ -72,27 +73,27 @@ afterAll(() => { // ── verifyTripAccess ────────────────────────────────────────────────────────── describe('verifyTripAccess', () => { - it('TODO-SVC-001: returns trip for owner', () => { + it('TODO-SVC-001: returns trip for owner', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.verifyTripAccess(trip.id, user.id); + const result = await svc.verifyTripAccess(trip.id, user.id); expect(result).toBeDefined(); expect(result?.id).toBe(trip.id); }); - it('TODO-SVC-002: returns null for non-member', () => { + it('TODO-SVC-002: returns null for non-member', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); + expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); }); - it('TODO-SVC-003: returns trip for member', () => { + it('TODO-SVC-003: returns trip for member', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const result = svc.verifyTripAccess(trip.id, member.id); + const result = await svc.verifyTripAccess(trip.id, member.id); expect(result).toBeDefined(); }); }); @@ -100,16 +101,16 @@ describe('verifyTripAccess', () => { // ── listItems / createItem ──────────────────────────────────────────────────── describe('listItems and createItem', () => { - it('TODO-SVC-004: listItems returns empty array for new trip', () => { + it('TODO-SVC-004: listItems returns empty array for new trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.listItems(trip.id)).toEqual([]); + expect(await svc.listItems(trip.id)).toEqual([]); }); - it('TODO-SVC-005: createItem inserts a todo with name only', () => { + it('TODO-SVC-005: createItem inserts a todo with name only', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Buy snacks' }) as any; + const item = (await svc.createItem(trip.id, { name: 'Buy snacks' })) as any; expect(item).toBeDefined(); expect(item.name).toBe('Buy snacks'); expect(item.checked).toBe(0); @@ -117,35 +118,35 @@ describe('listItems and createItem', () => { expect(item.sort_order).toBe(0); }); - it('TODO-SVC-006: createItem assigns incrementing sort_order', () => { + it('TODO-SVC-006: createItem assigns incrementing sort_order', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const a = svc.createItem(trip.id, { name: 'A' }) as any; - const b = svc.createItem(trip.id, { name: 'B' }) as any; + const a = (await svc.createItem(trip.id, { name: 'A' })) as any; + const b = (await svc.createItem(trip.id, { name: 'B' })) as any; expect(b.sort_order).toBe(a.sort_order + 1); }); - it('TODO-SVC-007: createItem stores optional fields', () => { + it('TODO-SVC-007: createItem stores optional fields', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { + const item = (await svc.createItem(trip.id, { name: 'Pack bag', category: 'Prep', description: 'All the gear', priority: 3, - }) as any; + })) as any; expect(item.category).toBe('Prep'); expect(item.description).toBe('All the gear'); expect(item.priority).toBe(3); }); - it('TODO-SVC-008: listItems returns items ordered by sort_order', () => { + it('TODO-SVC-008: listItems returns items ordered by sort_order', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - svc.createItem(trip.id, { name: 'First' }); - svc.createItem(trip.id, { name: 'Second' }); - svc.createItem(trip.id, { name: 'Third' }); - const items = svc.listItems(trip.id) as any[]; + await svc.createItem(trip.id, { name: 'First' }); + await svc.createItem(trip.id, { name: 'Second' }); + await svc.createItem(trip.id, { name: 'Third' }); + const items = (await svc.listItems(trip.id)) as any[]; expect(items).toHaveLength(3); expect(items[0].sort_order).toBeLessThanOrEqual(items[1].sort_order); expect(items[1].sort_order).toBeLessThanOrEqual(items[2].sort_order); @@ -155,36 +156,36 @@ describe('listItems and createItem', () => { // ── updateItem ──────────────────────────────────────────────────────────────── describe('updateItem', () => { - it('TODO-SVC-009: returns null for non-existent item', () => { + it('TODO-SVC-009: returns null for non-existent item', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull(); + expect(await svc.updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull(); }); - it('TODO-SVC-010: toggles checked status', () => { + it('TODO-SVC-010: toggles checked status', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Visit museum' }) as any; - const updated = svc.updateItem(trip.id, item.id, { checked: 1 }, ['checked']) as any; + const item = (await svc.createItem(trip.id, { name: 'Visit museum' })) as any; + const updated = (await svc.updateItem(trip.id, item.id, { checked: 1 }, ['checked'])) as any; expect(updated.checked).toBe(1); - const back = svc.updateItem(trip.id, item.id, { checked: 0 }, ['checked']) as any; + const back = (await svc.updateItem(trip.id, item.id, { checked: 0 }, ['checked'])) as any; expect(back.checked).toBe(0); }); - it('TODO-SVC-011: updates name and category', () => { + it('TODO-SVC-011: updates name and category', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Old' }) as any; - const updated = svc.updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category']) as any; + const item = (await svc.createItem(trip.id, { name: 'Old' })) as any; + const updated = (await svc.updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category'])) as any; expect(updated.name).toBe('New'); expect(updated.category).toBe('Misc'); }); - it('TODO-SVC-012: clears due_date when key is present with null value', () => { + it('TODO-SVC-012: clears due_date when key is present with null value', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Task', due_date: '2026-06-01' }) as any; - const updated = svc.updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any; + const item = (await svc.createItem(trip.id, { name: 'Task', due_date: '2026-06-01' })) as any; + const updated = (await svc.updateItem(trip.id, item.id, { due_date: null }, ['due_date'])) as any; expect(updated.due_date).toBeNull(); }); }); @@ -192,32 +193,32 @@ describe('updateItem', () => { // ── deleteItem ──────────────────────────────────────────────────────────────── describe('deleteItem', () => { - it('TODO-SVC-013: returns false for non-existent item', () => { + it('TODO-SVC-013: returns false for non-existent item', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.deleteItem(trip.id, 99999)).toBe(false); + expect(await svc.deleteItem(trip.id, 99999)).toBe(false); }); - it('TODO-SVC-014: deletes item and returns true', () => { + it('TODO-SVC-014: deletes item and returns true', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Gone' }) as any; - expect(svc.deleteItem(trip.id, item.id)).toBe(true); - expect(svc.listItems(trip.id)).toHaveLength(0); + const item = (await svc.createItem(trip.id, { name: 'Gone' })) as any; + expect(await svc.deleteItem(trip.id, item.id)).toBe(true); + expect(await svc.listItems(trip.id)).toHaveLength(0); }); }); // ── reorderItems ────────────────────────────────────────────────────────────── describe('reorderItems', () => { - it('TODO-SVC-015: assigns sort_order matching orderedIds array position', () => { + it('TODO-SVC-015: assigns sort_order matching orderedIds array position', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const a = svc.createItem(trip.id, { name: 'A' }) as any; - const b = svc.createItem(trip.id, { name: 'B' }) as any; - const c = svc.createItem(trip.id, { name: 'C' }) as any; + const a = (await svc.createItem(trip.id, { name: 'A' })) as any; + const b = (await svc.createItem(trip.id, { name: 'B' })) as any; + const c = (await svc.createItem(trip.id, { name: 'C' })) as any; - svc.reorderItems(trip.id, [c.id, a.id, b.id]); + await svc.reorderItems(trip.id, [c.id, a.id, b.id]); const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[]; expect(rows[0].id).toBe(c.id); @@ -229,47 +230,47 @@ describe('reorderItems', () => { // ── category assignees ──────────────────────────────────────────────────────── describe('getCategoryAssignees / updateCategoryAssignees', () => { - it('TODO-SVC-016: returns empty object for new trip', () => { + it('TODO-SVC-016: returns empty object for new trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.getCategoryAssignees(trip.id)).toEqual({}); + expect(await svc.getCategoryAssignees(trip.id)).toEqual({}); }); - it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', () => { + it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const rows = svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]); + const rows = await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]); expect(rows).toHaveLength(2); - const assignees = svc.getCategoryAssignees(trip.id); + const assignees = await svc.getCategoryAssignees(trip.id); expect(assignees['Packing']).toHaveLength(2); }); - it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', () => { + it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id]); - const cleared = svc.updateCategoryAssignees(trip.id, 'Packing', []); + await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id]); + const cleared = await svc.updateCategoryAssignees(trip.id, 'Packing', []); expect(cleared).toHaveLength(0); - const assignees = svc.getCategoryAssignees(trip.id); + const assignees = await svc.getCategoryAssignees(trip.id); expect(assignees['Packing']).toBeUndefined(); }); - it('TODO-SVC-019: getCategoryAssignees groups by category name', () => { + it('TODO-SVC-019: getCategoryAssignees groups by category name', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - svc.updateCategoryAssignees(trip.id, 'Shopping', [owner.id]); - svc.updateCategoryAssignees(trip.id, 'Logistics', [member.id]); + await svc.updateCategoryAssignees(trip.id, 'Shopping', [owner.id]); + await svc.updateCategoryAssignees(trip.id, 'Logistics', [member.id]); - const assignees = svc.getCategoryAssignees(trip.id); + const assignees = await svc.getCategoryAssignees(trip.id); expect(Object.keys(assignees)).toHaveLength(2); expect(assignees['Shopping']).toHaveLength(1); expect(assignees['Logistics']).toHaveLength(1); @@ -279,42 +280,42 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => { // you name belongs to it. The read-back joins users and hands the caller a // display name and an avatar, so an unfiltered id turns the endpoint into a // lookup for every account on the instance. - it('TODO-SVC-017a: drops a user who is not on the trip, and says nothing about them', () => { + it('TODO-SVC-017a: drops a user who is not on the trip, and says nothing about them', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const rows = svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, stranger.id, member.id]) as { user_id: number }[]; + const rows = (await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, stranger.id, member.id])) as { user_id: number }[]; expect(rows.map(r => r.user_id).sort()).toEqual([owner.id, member.id].sort()); expect(JSON.stringify(rows)).not.toContain(stranger.username); expect(testDb.prepare('SELECT COUNT(*) AS n FROM todo_category_assignees WHERE user_id = ?').get(stranger.id)).toEqual({ n: 0 }); }); - it('TODO-SVC-017b: keeps a guest, who is a trip member like any other', () => { + it('TODO-SVC-017b: keeps a guest, who is a trip member like any other', async () => { const { user: owner } = createUser(testDb); const { user: guest } = createUser(testDb); testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, guest.id); - const rows = svc.updateCategoryAssignees(trip.id, 'Packing', [guest.id]) as { user_id: number }[]; + const rows = (await svc.updateCategoryAssignees(trip.id, 'Packing', [guest.id])) as { user_id: number }[]; expect(rows.map(r => r.user_id)).toEqual([guest.id]); }); - it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', () => { + it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - svc.updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]); + await svc.updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]); // Replace with just owner - svc.updateCategoryAssignees(trip.id, 'Food', [owner.id]); + await svc.updateCategoryAssignees(trip.id, 'Food', [owner.id]); - const assignees = svc.getCategoryAssignees(trip.id); + const assignees = await svc.getCategoryAssignees(trip.id); expect(assignees['Food']).toHaveLength(1); expect(assignees['Food'][0].user_id).toBe(owner.id); }); @@ -334,7 +335,7 @@ describe('TodoService.canEdit', () => { it('TODO-SVC-090 asks for packing_edit and flags a non-owner as shared', async () => { const checkPermission = vi.fn(() => true); const permissions = { checkPermission } as unknown as PermissionsService; - const withStub = new TodoService(new DatabaseService(testDb), permissions, new RealtimeService()); + const withStub = new TodoService(new DatabaseService(testDb), permissions, new RealtimeService(), await createTestUnitOfWork(testDb)); const trip = { id: 1, user_id: 1 } as never; expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true); diff --git a/server/tests/unit/nest/trip-invite.service.test.ts b/server/tests/unit/nest/trip-invite.service.test.ts index e4c60aff2..73f9832b9 100644 --- a/server/tests/unit/nest/trip-invite.service.test.ts +++ b/server/tests/unit/nest/trip-invite.service.test.ts @@ -48,7 +48,8 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const dbs = new DatabaseService(testDb); let svc: TripInviteService; beforeAll(async () => { - svc = new TripInviteService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new TripMembershipService(dbs)); + const uow = await createTestUnitOfWork(dbs.connection); + svc = new TripInviteService(dbs, new PermissionsService(dbs, uow), new TripMembershipService(dbs), uow); }); beforeAll(() => { createTables(testDb); runMigrations(testDb); }); @@ -62,69 +63,69 @@ function setup() { } describe('TripInviteService', () => { - it('TRIP-INVITE-001: no link exists initially', () => { + it('TRIP-INVITE-001: no link exists initially', async () => { const { trip } = setup(); - expect(svc.get(trip.id)).toBeNull(); + expect(await svc.get(trip.id)).toBeNull(); }); - it('TRIP-INVITE-002: create returns a token and get reads it back', () => { + it('TRIP-INVITE-002: create returns a token and get reads it back', async () => { const { owner, trip } = setup(); - const info = svc.createOrRotate(trip.id, owner.id); + const info = await svc.createOrRotate(trip.id, owner.id); expect(info.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); - expect(svc.get(trip.id)?.token).toBe(info.token); + expect((await svc.get(trip.id))?.token).toBe(info.token); }); - it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', () => { + it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', async () => { const { owner, trip } = setup(); - const first = svc.createOrRotate(trip.id, owner.id); - const second = svc.createOrRotate(trip.id, owner.id); + const first = await svc.createOrRotate(trip.id, owner.id); + const second = await svc.createOrRotate(trip.id, owner.id); expect(second.token).not.toBe(first.token); const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; expect(count.n).toBe(1); // The old token no longer resolves. - expect(svc.resolve(first.token)).toBeNull(); + expect(await svc.resolve(first.token)).toBeNull(); }); - it('TRIP-INVITE-004: resolve returns the trip for a valid token', () => { + it('TRIP-INVITE-004: resolve returns the trip for a valid token', async () => { const { owner, trip } = setup(); - const info = svc.createOrRotate(trip.id, owner.id); - expect(svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); + const info = await svc.createOrRotate(trip.id, owner.id); + expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); }); - it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', () => { + it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', async () => { const { owner, trip } = setup(); - const info = svc.createOrRotate(trip.id, owner.id); + const info = await svc.createOrRotate(trip.id, owner.id); // Use the exact ISO-8601 format the service writes, one hour in the past — // this catches the lexicographic-SQL-comparison bug where a same-UTC-day // expiry would otherwise still resolve. testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') .run(new Date(Date.now() - 3600_000).toISOString(), trip.id); - expect(svc.resolve(info.token)).toBeNull(); + expect(await svc.resolve(info.token)).toBeNull(); }); - it('TRIP-INVITE-005b: a not-yet-expired token still resolves', () => { + it('TRIP-INVITE-005b: a not-yet-expired token still resolves', async () => { const { owner, trip } = setup(); - const info = svc.createOrRotate(trip.id, owner.id); + const info = await svc.createOrRotate(trip.id, owner.id); testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') .run(new Date(Date.now() + 3600_000).toISOString(), trip.id); - expect(svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); + expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); }); - it('TRIP-INVITE-006: delete removes the link', () => { + it('TRIP-INVITE-006: delete removes the link', async () => { const { owner, trip } = setup(); - const info = svc.createOrRotate(trip.id, owner.id); - svc.remove(trip.id); - expect(svc.get(trip.id)).toBeNull(); - expect(svc.resolve(info.token)).toBeNull(); + const info = await svc.createOrRotate(trip.id, owner.id); + await svc.remove(trip.id); + expect(await svc.get(trip.id)).toBeNull(); + expect(await svc.resolve(info.token)).toBeNull(); }); - it('TRIP-INVITE-007: an expiry in days is written as an ISO timestamp; non-positive means none', () => { + it('TRIP-INVITE-007: an expiry in days is written as an ISO timestamp; non-positive means none', async () => { const { owner, trip } = setup(); - const bounded = svc.createOrRotate(trip.id, owner.id, 7); + const bounded = await svc.createOrRotate(trip.id, owner.id, 7); const expires = new Date(bounded.expires_at!).getTime(); expect(expires).toBeGreaterThan(Date.now() + 6 * 86400000); expect(expires).toBeLessThan(Date.now() + 8 * 86400000); - expect(svc.createOrRotate(trip.id, owner.id, 0).expires_at).toBeNull(); - expect(svc.createOrRotate(trip.id, owner.id, -3).expires_at).toBeNull(); + expect((await svc.createOrRotate(trip.id, owner.id, 0)).expires_at).toBeNull(); + expect((await svc.createOrRotate(trip.id, owner.id, -3)).expires_at).toBeNull(); }); }); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index cdcebfa1a..7e3bbc168 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -120,7 +120,7 @@ const buildReadModel = async (database: DatabaseService, roster: TripMembersServ new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)), new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService()), placesSvc, - new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService()), + new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)), new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), makeStorageFixture('').storage), ); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 22d9fc62f..40aec1881 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -156,7 +156,7 @@ beforeAll(async () => { new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)), new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService()), placesSvc, - new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService()), + new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)), new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), coversFx.storage), ); });