From 05e59689fae3d9ab27ba8617ff51a077fd29181e Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Wed, 4 Mar 2026 03:20:18 +0800 Subject: [PATCH 1/3] fix(platform): handle unhandled promise rejections in query handlers Wrap async operations (storage.getUrl, db.get, runQuery) in try/catch blocks across branding, conversations, integrations, members, and team_members queries to prevent unhandled rejections from crashing reactive queries. Extract inline handlers into named testable functions where applicable. --- .../convex/branding/__tests__/queries.test.ts | 31 ++ services/platform/convex/branding/queries.ts | 19 +- .../conversations/transform_conversation.ts | 53 +-- .../platform/convex/integrations/queries.ts | 11 +- .../convex/members/__tests__/queries.test.ts | 344 ++++++++++++++++++ services/platform/convex/members/queries.ts | 329 +++++++++-------- .../platform/convex/team_members/queries.ts | 32 +- 7 files changed, 617 insertions(+), 202 deletions(-) create mode 100644 services/platform/convex/members/__tests__/queries.test.ts diff --git a/services/platform/convex/branding/__tests__/queries.test.ts b/services/platform/convex/branding/__tests__/queries.test.ts index e3f7776215..a1ff9978ee 100644 --- a/services/platform/convex/branding/__tests__/queries.test.ts +++ b/services/platform/convex/branding/__tests__/queries.test.ts @@ -155,4 +155,35 @@ describe('getBrandingHandler', () => { accentColor: undefined, }); }); + + it('returns null for URLs when storage fetch fails', async () => { + const { ctx } = createMockQueryCtx({ + _id: 'branding_1', + organizationId: 'org_1', + appName: 'Acme', + logoStorageId: 'storage_logo', + faviconLightStorageId: 'storage_fav_light', + faviconDarkStorageId: 'storage_fav_dark', + updatedAt: 1000, + }); + + ctx.storage.getUrl + .mockResolvedValueOnce('https://storage.example.com/storage_logo') + .mockRejectedValueOnce(new Error('Storage unavailable')) + .mockResolvedValueOnce('https://storage.example.com/storage_fav_dark'); + + const result = await getBrandingHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual({ + appName: 'Acme', + textLogo: undefined, + logoUrl: 'https://storage.example.com/storage_logo', + faviconLightUrl: null, + faviconDarkUrl: 'https://storage.example.com/storage_fav_dark', + brandColor: undefined, + accentColor: undefined, + }); + }); }); diff --git a/services/platform/convex/branding/queries.ts b/services/platform/convex/branding/queries.ts index a9741b9ae3..1b49f38226 100644 --- a/services/platform/convex/branding/queries.ts +++ b/services/platform/convex/branding/queries.ts @@ -22,14 +22,19 @@ export async function getBrandingHandler( if (!branding) return null; + async function safeGetUrl(storageId: string | undefined) { + if (!storageId) return null; + try { + return await ctx.storage.getUrl(storageId); + } catch { + return null; + } + } + const [logoUrl, faviconLightUrl, faviconDarkUrl] = await Promise.all([ - branding.logoStorageId ? ctx.storage.getUrl(branding.logoStorageId) : null, - branding.faviconLightStorageId - ? ctx.storage.getUrl(branding.faviconLightStorageId) - : null, - branding.faviconDarkStorageId - ? ctx.storage.getUrl(branding.faviconDarkStorageId) - : null, + safeGetUrl(branding.logoStorageId), + safeGetUrl(branding.faviconLightStorageId), + safeGetUrl(branding.faviconDarkStorageId), ]); return { diff --git a/services/platform/convex/conversations/transform_conversation.ts b/services/platform/convex/conversations/transform_conversation.ts index 910050de55..9413ddf722 100644 --- a/services/platform/convex/conversations/transform_conversation.ts +++ b/services/platform/convex/conversations/transform_conversation.ts @@ -20,30 +20,39 @@ export async function transformConversation( // Load customer and messages in parallel const [customerDoc, messageDocs] = await Promise.all([ - conversation.customerId - ? ctx.db.get(conversation.customerId) - : Promise.resolve(null), (async () => { - if (includeAllMessages) { - const docs: Array> = []; - for await (const msg of ctx.db - .query('conversationMessages') - .withIndex('by_conversationId_and_deliveredAt', (q) => - q.eq('conversationId', conversation._id), - )) { - docs.push(msg); + if (!conversation.customerId) return null; + try { + return await ctx.db.get(conversation.customerId); + } catch { + return null; + } + })(), + (async (): Promise>> => { + try { + if (includeAllMessages) { + const docs: Array> = []; + for await (const msg of ctx.db + .query('conversationMessages') + .withIndex('by_conversationId_and_deliveredAt', (q) => + q.eq('conversationId', conversation._id), + )) { + docs.push(msg); + } + return docs; + } else { + const lastMessage = await ctx.db + .query('conversationMessages') + .withIndex('by_conversationId_and_deliveredAt', (q) => + q.eq('conversationId', conversation._id), + ) + .order('desc') + .first(); + + return lastMessage ? [lastMessage] : []; } - return docs; - } else { - const lastMessage = await ctx.db - .query('conversationMessages') - .withIndex('by_conversationId_and_deliveredAt', (q) => - q.eq('conversationId', conversation._id), - ) - .order('desc') - .first(); - - return lastMessage ? [lastMessage] : []; + } catch { + return []; } })(), ]); diff --git a/services/platform/convex/integrations/queries.ts b/services/platform/convex/integrations/queries.ts index 70a0086676..a10a3647ea 100644 --- a/services/platform/convex/integrations/queries.ts +++ b/services/platform/convex/integrations/queries.ts @@ -15,9 +15,14 @@ async function withIconUrl( ctx: QueryCtx, integration: Doc<'integrations'>, ): Promise { - const iconUrl = integration.iconStorageId - ? await ctx.storage.getUrl(integration.iconStorageId) - : null; + let iconUrl: string | null = null; + if (integration.iconStorageId) { + try { + iconUrl = await ctx.storage.getUrl(integration.iconStorageId); + } catch { + // Graceful degradation: integration appears without icon + } + } return { ...integration, iconUrl }; } diff --git a/services/platform/convex/members/__tests__/queries.test.ts b/services/platform/convex/members/__tests__/queries.test.ts new file mode 100644 index 0000000000..826116d770 --- /dev/null +++ b/services/platform/convex/members/__tests__/queries.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { QueryCtx } from '../../_generated/server'; + +vi.mock('../../_generated/api', () => ({ + components: { + betterAuth: { + adapter: { + findMany: 'betterAuth:adapter:findMany', + findOne: 'betterAuth:adapter:findOne', + }, + }, + }, +})); + +vi.mock('../../lib/rls', () => ({ + getAuthUserIdentity: vi.fn(), + getOrganizationMember: vi.fn(), + getUserOrganizations: vi.fn(), +})); + +vi.mock('../../lib/rls/errors', () => ({ + UnauthenticatedError: class extends Error { + constructor() { + super('Unauthenticated'); + } + }, + UnauthorizedError: class extends Error { + constructor() { + super('Unauthorized'); + } + }, +})); + +vi.mock('convex/values', () => { + const stub = () => 'validator'; + return { + v: { + string: stub, + number: stub, + boolean: stub, + optional: stub, + union: stub, + object: stub, + literal: stub, + array: stub, + null: stub, + id: stub, + }, + }; +}); + +vi.mock('../validators', () => ({ + memberRoleValidator: 'memberRoleValidator', +})); + +vi.mock('../../_generated/server', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + query: (config: Record) => config, + }; +}); + +const { getAuthUserIdentity, getOrganizationMember } = + await import('../../lib/rls'); +const { + getMyTeamsHandler, + approxCountMyTeamsHandler, + listByOrganizationHandler, +} = await import('../queries'); + +const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); +const mockedGetOrgMember = vi.mocked(getOrganizationMember); + +function createMockCtx() { + return { + runQuery: vi.fn(), + db: {}, + auth: {}, + }; +} + +describe('getMyTeamsHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('returns teams for user with memberships', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ + page: [{ _id: 'team_1', name: 'Alpha', organizationId: 'org_1' }], + }) + .mockResolvedValueOnce({ + page: [{ _id: 'team_2', name: 'Beta', organizationId: 'org_1' }], + }); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { id: 'team_1', name: 'Alpha' }, + { id: 'team_2', name: 'Beta' }, + ]); + }); + + it('returns partial results when some team lookups fail', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + { _id: 'tm_3', teamId: 'team_3', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ + page: [{ _id: 'team_1', name: 'Alpha', organizationId: 'org_1' }], + }) + .mockRejectedValueOnce(new Error('DB connection lost')) + .mockResolvedValueOnce({ + page: [{ _id: 'team_3', name: 'Gamma', organizationId: 'org_1' }], + }); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { id: 'team_1', name: 'Alpha' }, + { id: 'team_3', name: 'Gamma' }, + ]); + }); + + it('returns empty array when all team lookups fail', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); +}); + +describe('approxCountMyTeamsHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('counts teams correctly', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ page: [{ _id: 'team_1' }] }) + .mockResolvedValueOnce({ page: [{ _id: 'team_2' }] }); + + const result = await approxCountMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toBe(2); + }); + + it('counts only successful lookups on partial failure', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { _id: 'tm_1', teamId: 'team_1', userId: 'user_1' }, + { _id: 'tm_2', teamId: 'team_2', userId: 'user_1' }, + { _id: 'tm_3', teamId: 'team_3', userId: 'user_1' }, + ], + }); + + ctx.runQuery + .mockResolvedValueOnce({ page: [{ _id: 'team_1' }] }) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ page: [{ _id: 'team_3' }] }); + + const result = await approxCountMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toBe(2); + }); +}); + +describe('listByOrganizationHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns members with user details', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_1', + createdAt: 1000, + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'admin', + createdAt: 1000, + }, + ], + }); + + ctx.runQuery.mockResolvedValueOnce({ + name: 'Alice', + email: 'alice@example.com', + }); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'admin', + createdAt: 1000, + displayName: 'Alice', + email: 'alice@example.com', + }, + ]); + }); + + it('returns member without name/email when user lookup fails', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_1', + createdAt: 1000, + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'member', + createdAt: 1000, + }, + { + _id: 'm_2', + organizationId: 'org_1', + userId: 'u_2', + role: 'admin', + createdAt: 2000, + }, + ], + }); + + ctx.runQuery + .mockRejectedValueOnce(new Error('lookup failed')) + .mockResolvedValueOnce({ name: 'Bob', email: 'bob@example.com' }); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([ + { + _id: 'm_1', + organizationId: 'org_1', + userId: 'u_1', + role: 'member', + createdAt: 1000, + displayName: undefined, + email: undefined, + }, + { + _id: 'm_2', + organizationId: 'org_1', + userId: 'u_2', + role: 'admin', + createdAt: 2000, + displayName: 'Bob', + email: 'bob@example.com', + }, + ]); + }); +}); diff --git a/services/platform/convex/members/queries.ts b/services/platform/convex/members/queries.ts index d8db221122..98d5929a19 100644 --- a/services/platform/convex/members/queries.ts +++ b/services/platform/convex/members/queries.ts @@ -4,7 +4,7 @@ import type { MemberRole } from '../../lib/shared/schemas/organizations'; import type { BetterAuthFindManyResult, BetterAuthMember } from './types'; import { components } from '../_generated/api'; -import { query } from '../_generated/server'; +import { query, QueryCtx } from '../_generated/server'; import { getAuthUserIdentity, getOrganizationMember, @@ -13,13 +13,6 @@ import { import { UnauthenticatedError, UnauthorizedError } from '../lib/rls/errors'; import { memberRoleValidator } from './validators'; -interface BetterAuthTeam { - _id: string; - name: string; - organizationId: string; - createdAt?: number | null; -} - interface BetterAuthTeamMember { _id: string; teamId: string; @@ -86,6 +79,75 @@ export const getCurrentMemberContext = query({ }, }); +export async function listByOrganizationHandler( + ctx: QueryCtx, + args: { organizationId: string }, +) { + const authUser = await getAuthUserIdentity(ctx); + if (!authUser) { + return []; + } + + try { + await getOrganizationMember(ctx, args.organizationId, authUser); + } catch { + return []; + } + + const result: BetterAuthFindManyResult = await ctx.runQuery( + components.betterAuth.adapter.findMany, + { + model: 'member', + paginationOpts: { cursor: null, numItems: 100 }, + where: [ + { + field: 'organizationId', + value: args.organizationId, + operator: 'eq', + }, + ], + }, + ); + + if (!result || result.page.length === 0) { + return []; + } + + return Promise.all( + result.page.map(async (member) => { + let displayName: string | undefined; + let email: string | undefined; + try { + const userResult = await ctx.runQuery( + components.betterAuth.adapter.findOne, + { + model: 'user', + where: [{ field: '_id', value: member.userId, operator: 'eq' }], + }, + ); + displayName = userResult?.name; + email = userResult?.email; + } catch { + // Graceful degradation: member appears without name/email + } + + const role: MemberRole = isValidRole(member.role) + ? member.role + : 'member'; + + return { + _id: member._id, + organizationId: member.organizationId, + userId: member.userId, + role, + createdAt: member.createdAt, + displayName, + email, + }; + }), + ); +} + export const listByOrganization = query({ args: { organizationId: v.string() }, returns: v.array( @@ -99,61 +161,7 @@ export const listByOrganization = query({ email: v.optional(v.string()), }), ), - handler: async (ctx, args) => { - const authUser = await getAuthUserIdentity(ctx); - if (!authUser) { - return []; - } - - try { - await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return []; - } - - const result: BetterAuthFindManyResult = - await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'member', - paginationOpts: { cursor: null, numItems: 100 }, - where: [ - { - field: 'organizationId', - value: args.organizationId, - operator: 'eq', - }, - ], - }); - - if (!result || result.page.length === 0) { - return []; - } - - return Promise.all( - result.page.map(async (member) => { - const userResult = await ctx.runQuery( - components.betterAuth.adapter.findOne, - { - model: 'user', - where: [{ field: '_id', value: member.userId, operator: 'eq' }], - }, - ); - - const role: MemberRole = isValidRole(member.role) - ? member.role - : 'member'; - - return { - _id: member._id, - organizationId: member.organizationId, - userId: member.userId, - role, - createdAt: member.createdAt, - displayName: userResult?.name, - email: userResult?.email, - }; - }), - ); - }, + handler: listByOrganizationHandler, }); export const getUserIdByEmail = query({ @@ -198,108 +206,115 @@ export const getUserOrganizationsList = query({ }, }); -export const approxCountMyTeams = query({ - args: { - organizationId: v.string(), - }, - returns: v.number(), - handler: async (ctx, args) => { - const authUser = await getAuthUserIdentity(ctx); - if (!authUser) { - return 0; - } - - const membershipsResult: BetterAuthFindManyResult = - await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'teamMember', - paginationOpts: { cursor: null, numItems: 100 }, - where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], - }); - - if (!membershipsResult || membershipsResult.page.length === 0) { - return 0; - } - - const teamResults: BetterAuthFindManyResult[] = - await Promise.all( - membershipsResult.page.map((membership) => - ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'team', - paginationOpts: { cursor: null, numItems: 1 }, - where: [ - { field: '_id', operator: 'eq', value: membership.teamId }, - { - field: 'organizationId', - operator: 'eq', - value: args.organizationId, - }, - ], - }), - ), - ); - - return teamResults.filter((r) => r && r.page.length > 0).length; - }, -}); +export async function approxCountMyTeamsHandler( + ctx: QueryCtx, + args: { organizationId: string }, +) { + const authUser = await getAuthUserIdentity(ctx); + if (!authUser) { + return 0; + } + + const membershipsResult: BetterAuthFindManyResult = + await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'teamMember', + paginationOpts: { cursor: null, numItems: 100 }, + where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], + }); -export const getMyTeams = query({ - args: { - organizationId: v.string(), - }, - returns: v.array( - v.object({ - id: v.string(), - name: v.string(), + if (!membershipsResult || membershipsResult.page.length === 0) { + return 0; + } + + const teamResults = await Promise.all( + membershipsResult.page.map(async (membership) => { + try { + return await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'team', + paginationOpts: { cursor: null, numItems: 1 }, + where: [ + { field: '_id', operator: 'eq', value: membership.teamId }, + { + field: 'organizationId', + operator: 'eq', + value: args.organizationId, + }, + ], + }); + } catch { + return null; + } }), - ), - handler: async (ctx, args) => { - const authUser = await getAuthUserIdentity(ctx); - if (!authUser) { - return []; - } + ); - const membershipsResult: BetterAuthFindManyResult = - await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'teamMember', - paginationOpts: { cursor: null, numItems: 100 }, - where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], - }); + return teamResults.filter((r) => r && r.page.length > 0).length; +} - if (!membershipsResult || membershipsResult.page.length === 0) { - return []; - } +export const approxCountMyTeams = query({ + args: { organizationId: v.string() }, + returns: v.number(), + handler: approxCountMyTeamsHandler, +}); - const teamIds = membershipsResult.page.map((m) => m.teamId); - - const teamResults: BetterAuthFindManyResult[] = - await Promise.all( - teamIds.map((teamId) => - ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'team', - paginationOpts: { cursor: null, numItems: 1 }, - where: [ - { field: '_id', operator: 'eq', value: teamId }, - { - field: 'organizationId', - operator: 'eq', - value: args.organizationId, - }, - ], - }), - ), - ); +export async function getMyTeamsHandler( + ctx: QueryCtx, + args: { organizationId: string }, +) { + const authUser = await getAuthUserIdentity(ctx); + if (!authUser) { + return []; + } + + const membershipsResult: BetterAuthFindManyResult = + await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'teamMember', + paginationOpts: { cursor: null, numItems: 100 }, + where: [{ field: 'userId', operator: 'eq', value: authUser.userId }], + }); - const teams: Array<{ id: string; name: string }> = []; - for (const teamResult of teamResults) { - if (teamResult && teamResult.page.length > 0) { - const team = teamResult.page[0]; - teams.push({ - id: team._id, - name: team.name, + if (!membershipsResult || membershipsResult.page.length === 0) { + return []; + } + + const teamIds = membershipsResult.page.map((m) => m.teamId); + + const teamResults = await Promise.all( + teamIds.map(async (teamId) => { + try { + return await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'team', + paginationOpts: { cursor: null, numItems: 1 }, + where: [ + { field: '_id', operator: 'eq', value: teamId }, + { + field: 'organizationId', + operator: 'eq', + value: args.organizationId, + }, + ], }); + } catch { + return null; } + }), + ); + + const teams: Array<{ id: string; name: string }> = []; + for (const teamResult of teamResults) { + if (teamResult && teamResult.page.length > 0) { + const team = teamResult.page[0]; + teams.push({ + id: team._id, + name: team.name, + }); } + } - return teams; - }, + return teams; +} + +export const getMyTeams = query({ + args: { organizationId: v.string() }, + returns: v.array(v.object({ id: v.string(), name: v.string() })), + handler: getMyTeamsHandler, }); diff --git a/services/platform/convex/team_members/queries.ts b/services/platform/convex/team_members/queries.ts index 447afcd263..0fcbab4149 100644 --- a/services/platform/convex/team_members/queries.ts +++ b/services/platform/convex/team_members/queries.ts @@ -66,19 +66,25 @@ export const listByTeam = query({ const userMap = new Map(); await Promise.all( [...userIds].map(async (userId) => { - const userResult = await ctx.runQuery( - components.betterAuth.adapter.findOne, - { - model: 'user', - where: [{ field: '_id', value: userId, operator: 'eq' }], - }, - ); - if (userResult) { - const name = - typeof userResult.name === 'string' ? userResult.name : undefined; - const email = - typeof userResult.email === 'string' ? userResult.email : undefined; - userMap.set(userId, { name, email }); + try { + const userResult = await ctx.runQuery( + components.betterAuth.adapter.findOne, + { + model: 'user', + where: [{ field: '_id', value: userId, operator: 'eq' }], + }, + ); + if (userResult) { + const name = + typeof userResult.name === 'string' ? userResult.name : undefined; + const email = + typeof userResult.email === 'string' + ? userResult.email + : undefined; + userMap.set(userId, { name, email }); + } + } catch { + // Graceful degradation: member appears without name/email } }), ); From 40e0b2b4258dec86e871168df3c3960cbcb9cb41 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Wed, 4 Mar 2026 11:21:15 +0800 Subject: [PATCH 2/3] fix(platform): narrow catch blocks to re-throw unexpected errors Replace blanket catch-all blocks with targeted UnauthorizedError checks so unexpected failures (e.g. DB connection errors) propagate instead of being silently swallowed. Add diagnostic console.warn logging to remaining graceful-degradation catches and remove unnecessary try-catch wrappers in transform_conversation. Add test coverage for auth edge cases and error re-throwing. --- services/platform/convex/branding/queries.ts | 7 +- .../conversations/transform_conversation.ts | 53 ++-- .../integrations/__tests__/queries.test.ts | 254 ++++++++++++++++++ .../platform/convex/integrations/queries.ts | 30 ++- .../convex/members/__tests__/queries.test.ts | 93 ++++++- services/platform/convex/members/queries.ts | 110 +++++--- .../team_members/__tests__/queries.test.ts | 222 +++++++++++++++ .../platform/convex/team_members/queries.ts | 16 +- 8 files changed, 686 insertions(+), 99 deletions(-) create mode 100644 services/platform/convex/integrations/__tests__/queries.test.ts create mode 100644 services/platform/convex/team_members/__tests__/queries.test.ts diff --git a/services/platform/convex/branding/queries.ts b/services/platform/convex/branding/queries.ts index 1b49f38226..3fbe70a105 100644 --- a/services/platform/convex/branding/queries.ts +++ b/services/platform/convex/branding/queries.ts @@ -26,7 +26,12 @@ export async function getBrandingHandler( if (!storageId) return null; try { return await ctx.storage.getUrl(storageId); - } catch { + } catch (error) { + console.warn( + '[Branding] Failed to resolve storage URL', + storageId, + error, + ); return null; } } diff --git a/services/platform/convex/conversations/transform_conversation.ts b/services/platform/convex/conversations/transform_conversation.ts index 9413ddf722..910050de55 100644 --- a/services/platform/convex/conversations/transform_conversation.ts +++ b/services/platform/convex/conversations/transform_conversation.ts @@ -20,39 +20,30 @@ export async function transformConversation( // Load customer and messages in parallel const [customerDoc, messageDocs] = await Promise.all([ + conversation.customerId + ? ctx.db.get(conversation.customerId) + : Promise.resolve(null), (async () => { - if (!conversation.customerId) return null; - try { - return await ctx.db.get(conversation.customerId); - } catch { - return null; - } - })(), - (async (): Promise>> => { - try { - if (includeAllMessages) { - const docs: Array> = []; - for await (const msg of ctx.db - .query('conversationMessages') - .withIndex('by_conversationId_and_deliveredAt', (q) => - q.eq('conversationId', conversation._id), - )) { - docs.push(msg); - } - return docs; - } else { - const lastMessage = await ctx.db - .query('conversationMessages') - .withIndex('by_conversationId_and_deliveredAt', (q) => - q.eq('conversationId', conversation._id), - ) - .order('desc') - .first(); - - return lastMessage ? [lastMessage] : []; + if (includeAllMessages) { + const docs: Array> = []; + for await (const msg of ctx.db + .query('conversationMessages') + .withIndex('by_conversationId_and_deliveredAt', (q) => + q.eq('conversationId', conversation._id), + )) { + docs.push(msg); } - } catch { - return []; + return docs; + } else { + const lastMessage = await ctx.db + .query('conversationMessages') + .withIndex('by_conversationId_and_deliveredAt', (q) => + q.eq('conversationId', conversation._id), + ) + .order('desc') + .first(); + + return lastMessage ? [lastMessage] : []; } })(), ]); diff --git a/services/platform/convex/integrations/__tests__/queries.test.ts b/services/platform/convex/integrations/__tests__/queries.test.ts new file mode 100644 index 0000000000..6ad1801fce --- /dev/null +++ b/services/platform/convex/integrations/__tests__/queries.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { Doc } from '../../_generated/dataModel'; + +vi.mock('../../_generated/api', () => ({ + components: { + betterAuth: { + adapter: { + findMany: 'betterAuth:adapter:findMany', + findOne: 'betterAuth:adapter:findOne', + }, + }, + }, +})); + +vi.mock('../../lib/rls', () => ({ + getAuthUserIdentity: vi.fn(), + getOrganizationMember: vi.fn(), +})); + +vi.mock('../../lib/rls/errors', () => { + class RLSError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'RLSError'; + } + } + return { + UnauthorizedError: class extends RLSError { + constructor() { + super('Not authorized to access this resource', 'UNAUTHORIZED'); + } + }, + }; +}); + +vi.mock('convex/values', () => { + const stub = () => 'validator'; + return { + v: { + string: stub, + number: stub, + boolean: stub, + optional: stub, + union: stub, + object: stub, + literal: stub, + array: stub, + null: stub, + id: stub, + }, + }; +}); + +vi.mock('../validators', () => ({ + integrationDocValidator: 'integrationDocValidator', +})); + +vi.mock('../get_integration', () => ({ + getIntegration: vi.fn(), +})); + +vi.mock('../get_integration_by_name', () => ({ + getIntegrationByName: vi.fn(), +})); + +vi.mock('../list_integrations', () => ({ + listIntegrations: vi.fn(), +})); + +vi.mock('../../_generated/server', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + query: (config: Record) => config, + }; +}); + +const { getAuthUserIdentity, getOrganizationMember } = + await import('../../lib/rls'); +const { UnauthorizedError } = await import('../../lib/rls/errors'); +const { getIntegration } = await import('../get_integration'); +const { listIntegrations } = await import('../list_integrations'); + +const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); +const mockedGetOrgMember = vi.mocked(getOrganizationMember); +const mockedGetIntegration = vi.mocked(getIntegration); +const mockedListIntegrations = vi.mocked(listIntegrations); + +function createMockCtx() { + return { + runQuery: vi.fn(), + storage: { getUrl: vi.fn() }, + db: {}, + auth: {}, + }; +} + +function makeIntegrationDoc( + overrides: Record = {}, +): Doc<'integrations'> { + return { + _id: 'int_1', + _creationTime: 1000, + organizationId: 'org_1', + name: 'Test Integration', + type: 'rest_api', + iconStorageId: undefined, + ...overrides, + } as unknown as Doc<'integrations'>; +} + +describe('get handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toBeNull(); + }); + + it('returns null when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetIntegration.mockResolvedValue(makeIntegrationDoc()); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toBeNull(); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetIntegration.mockResolvedValue(makeIntegrationDoc()); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const ctx = createMockCtx(); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + await expect(handler(ctx, { integrationId: 'int_1' })).rejects.toThrow( + 'DB failure', + ); + }); + + it('returns integration with icon URL on success', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const doc = makeIntegrationDoc({ iconStorageId: 'storage_icon' }); + mockedGetIntegration.mockResolvedValue(doc); + mockedGetOrgMember.mockResolvedValue({} as never); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockResolvedValue('https://storage.example.com/icon'); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toMatchObject({ + _id: 'int_1', + iconUrl: 'https://storage.example.com/icon', + }); + }); + + it('returns null iconUrl when storage fetch fails', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const doc = makeIntegrationDoc({ iconStorageId: 'storage_icon' }); + mockedGetIntegration.mockResolvedValue(doc); + mockedGetOrgMember.mockResolvedValue({} as never); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockRejectedValue(new Error('Storage unavailable')); + const { get } = await import('../queries'); + const handler = (get as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { integrationId: 'int_1' }); + + expect(result).toMatchObject({ + _id: 'int_1', + iconUrl: null, + }); + }); +}); + +describe('list handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual([]); + }); + + it('returns empty array when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual([]); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new Error('System error')); + const ctx = createMockCtx(); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + await expect(handler(ctx, { organizationId: 'org_1' })).rejects.toThrow( + 'System error', + ); + }); + + it('returns integrations with icon URLs', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({} as never); + const docs = [ + makeIntegrationDoc({ _id: 'int_1', iconStorageId: 'sid_1' }), + makeIntegrationDoc({ _id: 'int_2' }), + ]; + mockedListIntegrations.mockResolvedValue(docs); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockResolvedValueOnce('https://storage.example.com/i1'); + const { list } = await import('../queries'); + const handler = (list as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toHaveLength(2); + expect(result[0].iconUrl).toBe('https://storage.example.com/i1'); + expect(result[1].iconUrl).toBeNull(); + }); +}); diff --git a/services/platform/convex/integrations/queries.ts b/services/platform/convex/integrations/queries.ts index a10a3647ea..0a7e479d16 100644 --- a/services/platform/convex/integrations/queries.ts +++ b/services/platform/convex/integrations/queries.ts @@ -4,6 +4,7 @@ import type { Doc } from '../_generated/dataModel'; import { query, QueryCtx } from '../_generated/server'; import { getAuthUserIdentity, getOrganizationMember } from '../lib/rls'; +import { UnauthorizedError } from '../lib/rls/errors'; import { getIntegration } from './get_integration'; import { getIntegrationByName } from './get_integration_by_name'; import { listIntegrations } from './list_integrations'; @@ -19,8 +20,12 @@ async function withIconUrl( if (integration.iconStorageId) { try { iconUrl = await ctx.storage.getUrl(integration.iconStorageId); - } catch { - // Graceful degradation: integration appears without icon + } catch (error) { + console.warn( + '[Integrations] Failed to resolve icon URL', + integration.iconStorageId, + error, + ); } } return { ...integration, iconUrl }; @@ -44,8 +49,11 @@ export const get = query({ try { await getOrganizationMember(ctx, integration.organizationId, authUser); - } catch { - return null; + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; } return await withIconUrl(ctx, integration); @@ -66,8 +74,11 @@ export const getByName = query({ try { await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return null; + } catch (error) { + if (error instanceof UnauthorizedError) { + return null; + } + throw error; } const integration = await getIntegrationByName(ctx, args); @@ -93,8 +104,11 @@ export const list = query({ try { await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return []; + } catch (error) { + if (error instanceof UnauthorizedError) { + return []; + } + throw error; } const integrations = await listIntegrations(ctx, args); diff --git a/services/platform/convex/members/__tests__/queries.test.ts b/services/platform/convex/members/__tests__/queries.test.ts index 826116d770..ae4b11870f 100644 --- a/services/platform/convex/members/__tests__/queries.test.ts +++ b/services/platform/convex/members/__tests__/queries.test.ts @@ -19,18 +19,29 @@ vi.mock('../../lib/rls', () => ({ getUserOrganizations: vi.fn(), })); -vi.mock('../../lib/rls/errors', () => ({ - UnauthenticatedError: class extends Error { - constructor() { - super('Unauthenticated'); +vi.mock('../../lib/rls/errors', () => { + class RLSError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'RLSError'; } - }, - UnauthorizedError: class extends Error { - constructor() { - super('Unauthorized'); - } - }, -})); + } + return { + UnauthenticatedError: class extends RLSError { + constructor() { + super('Authentication required', 'UNAUTHENTICATED'); + } + }, + UnauthorizedError: class extends RLSError { + constructor() { + super('Not authorized to access this resource', 'UNAUTHORIZED'); + } + }, + }; +}); vi.mock('convex/values', () => { const stub = () => 'validator'; @@ -64,6 +75,7 @@ vi.mock('../../_generated/server', async (importOriginal) => { const { getAuthUserIdentity, getOrganizationMember } = await import('../../lib/rls'); +const { UnauthorizedError } = await import('../../lib/rls/errors'); const { getMyTeamsHandler, approxCountMyTeamsHandler, @@ -97,6 +109,19 @@ describe('getMyTeamsHandler', () => { expect(result).toEqual([]); }); + it('returns empty array when user has no team memberships', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ page: [] }); + + const result = await getMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + it('returns teams for user with memberships', async () => { mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); const ctx = createMockCtx(); @@ -185,6 +210,17 @@ describe('approxCountMyTeamsHandler', () => { vi.clearAllMocks(); }); + it('returns 0 when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await approxCountMyTeamsHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toBe(0); + }); + it('counts teams correctly', async () => { mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); const ctx = createMockCtx(); @@ -237,6 +273,41 @@ describe('listByOrganizationHandler', () => { vi.clearAllMocks(); }); + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('returns empty array when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + + const result = await listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }); + + expect(result).toEqual([]); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new Error('DB connection lost')); + const ctx = createMockCtx(); + + await expect( + listByOrganizationHandler(ctx as unknown as QueryCtx, { + organizationId: 'org_1', + }), + ).rejects.toThrow('DB connection lost'); + }); + it('returns members with user details', async () => { mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); mockedGetOrgMember.mockResolvedValue({ diff --git a/services/platform/convex/members/queries.ts b/services/platform/convex/members/queries.ts index 98d5929a19..922270e72c 100644 --- a/services/platform/convex/members/queries.ts +++ b/services/platform/convex/members/queries.ts @@ -13,6 +13,13 @@ import { import { UnauthenticatedError, UnauthorizedError } from '../lib/rls/errors'; import { memberRoleValidator } from './validators'; +interface BetterAuthTeam { + _id: string; + name: string; + organizationId: string; + createdAt?: number | null; +} + interface BetterAuthTeamMember { _id: string; teamId: string; @@ -90,8 +97,11 @@ export async function listByOrganizationHandler( try { await getOrganizationMember(ctx, args.organizationId, authUser); - } catch { - return []; + } catch (error) { + if (error instanceof UnauthorizedError) { + return []; + } + throw error; } const result: BetterAuthFindManyResult = await ctx.runQuery( @@ -127,8 +137,12 @@ export async function listByOrganizationHandler( ); displayName = userResult?.name; email = userResult?.email; - } catch { - // Graceful degradation: member appears without name/email + } catch (error) { + console.warn( + '[Members] Failed to fetch user details', + member.userId, + error, + ); } const role: MemberRole = isValidRole(member.role) @@ -226,26 +240,32 @@ export async function approxCountMyTeamsHandler( return 0; } - const teamResults = await Promise.all( - membershipsResult.page.map(async (membership) => { - try { - return await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'team', - paginationOpts: { cursor: null, numItems: 1 }, - where: [ - { field: '_id', operator: 'eq', value: membership.teamId }, - { - field: 'organizationId', - operator: 'eq', - value: args.organizationId, - }, - ], - }); - } catch { - return null; - } - }), - ); + const teamResults: (BetterAuthFindManyResult | null)[] = + await Promise.all( + membershipsResult.page.map(async (membership) => { + try { + return await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'team', + paginationOpts: { cursor: null, numItems: 1 }, + where: [ + { field: '_id', operator: 'eq', value: membership.teamId }, + { + field: 'organizationId', + operator: 'eq', + value: args.organizationId, + }, + ], + }); + } catch (error) { + console.warn( + '[Members] Failed to look up team', + membership.teamId, + error, + ); + return null; + } + }), + ); return teamResults.filter((r) => r && r.page.length > 0).length; } @@ -278,26 +298,28 @@ export async function getMyTeamsHandler( const teamIds = membershipsResult.page.map((m) => m.teamId); - const teamResults = await Promise.all( - teamIds.map(async (teamId) => { - try { - return await ctx.runQuery(components.betterAuth.adapter.findMany, { - model: 'team', - paginationOpts: { cursor: null, numItems: 1 }, - where: [ - { field: '_id', operator: 'eq', value: teamId }, - { - field: 'organizationId', - operator: 'eq', - value: args.organizationId, - }, - ], - }); - } catch { - return null; - } - }), - ); + const teamResults: (BetterAuthFindManyResult | null)[] = + await Promise.all( + teamIds.map(async (teamId) => { + try { + return await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'team', + paginationOpts: { cursor: null, numItems: 1 }, + where: [ + { field: '_id', operator: 'eq', value: teamId }, + { + field: 'organizationId', + operator: 'eq', + value: args.organizationId, + }, + ], + }); + } catch (error) { + console.warn('[Members] Failed to look up team', teamId, error); + return null; + } + }), + ); const teams: Array<{ id: string; name: string }> = []; for (const teamResult of teamResults) { diff --git a/services/platform/convex/team_members/__tests__/queries.test.ts b/services/platform/convex/team_members/__tests__/queries.test.ts new file mode 100644 index 0000000000..d3a906a8c7 --- /dev/null +++ b/services/platform/convex/team_members/__tests__/queries.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../_generated/api', () => ({ + components: { + betterAuth: { + adapter: { + findMany: 'betterAuth:adapter:findMany', + findOne: 'betterAuth:adapter:findOne', + }, + }, + }, +})); + +vi.mock('../../lib/rls', () => ({ + getAuthUserIdentity: vi.fn(), + getOrganizationMember: vi.fn(), +})); + +vi.mock('../../lib/rls/errors', () => { + class RLSError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'RLSError'; + } + } + return { + UnauthorizedError: class extends RLSError { + constructor() { + super('Not authorized to access this resource', 'UNAUTHORIZED'); + } + }, + }; +}); + +vi.mock('convex/values', () => { + const stub = () => 'validator'; + return { + v: { + string: stub, + number: stub, + boolean: stub, + optional: stub, + union: stub, + object: stub, + literal: stub, + array: stub, + null: stub, + id: stub, + }, + }; +}); + +vi.mock('../../_generated/server', async (importOriginal) => { + const mod = await importOriginal>(); + return { + ...mod, + query: (config: Record) => config, + }; +}); + +const { getAuthUserIdentity, getOrganizationMember } = + await import('../../lib/rls'); +const { UnauthorizedError } = await import('../../lib/rls/errors'); + +const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); +const mockedGetOrgMember = vi.mocked(getOrganizationMember); + +function createMockCtx() { + return { + runQuery: vi.fn(), + db: {}, + auth: {}, + }; +} + +describe('listByTeam handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function getHandler() { + const { listByTeam } = await import('../queries'); + return (listByTeam as unknown as { handler: Function }).handler; + } + + it('returns empty array when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([]); + }); + + it('returns empty array when team not found', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + ctx.runQuery.mockResolvedValueOnce(null); + const handler = await getHandler(); + + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([]); + }); + + it('returns empty array when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const handler = await getHandler(); + + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([]); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const handler = await getHandler(); + + await expect(handler(ctx, { teamId: 'team_1' })).rejects.toThrow( + 'DB failure', + ); + }); + + it('returns members with user details', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockResolvedValue({} as never); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'admin', + createdAt: 1000, + }, + ], + }); + + ctx.runQuery.mockResolvedValueOnce({ + name: 'Alice', + email: 'alice@example.com', + }); + + const handler = await getHandler(); + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'admin', + joinedAt: 1000, + displayName: 'Alice', + email: 'alice@example.com', + }, + ]); + }); + + it('returns member without name/email when user lookup fails', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + const ctx = createMockCtx(); + + ctx.runQuery.mockResolvedValueOnce({ + _id: 'team_1', + organizationId: 'org_1', + }); + mockedGetOrgMember.mockResolvedValue({} as never); + + ctx.runQuery.mockResolvedValueOnce({ + page: [ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'member', + createdAt: 2000, + }, + ], + }); + + ctx.runQuery.mockRejectedValueOnce(new Error('lookup failed')); + + const handler = await getHandler(); + const result = await handler(ctx, { teamId: 'team_1' }); + + expect(result).toEqual([ + { + _id: 'tm_1', + teamId: 'team_1', + userId: 'u_1', + role: 'member', + joinedAt: 2000, + displayName: undefined, + email: undefined, + }, + ]); + }); +}); diff --git a/services/platform/convex/team_members/queries.ts b/services/platform/convex/team_members/queries.ts index 0fcbab4149..1c18b2d9dc 100644 --- a/services/platform/convex/team_members/queries.ts +++ b/services/platform/convex/team_members/queries.ts @@ -3,6 +3,7 @@ import { v } from 'convex/values'; import { components } from '../_generated/api'; import { query } from '../_generated/server'; import { getAuthUserIdentity, getOrganizationMember } from '../lib/rls'; +import { UnauthorizedError } from '../lib/rls/errors'; export const listByTeam = query({ args: { @@ -35,8 +36,11 @@ export const listByTeam = query({ try { await getOrganizationMember(ctx, team.organizationId, authUser); - } catch { - return []; + } catch (error) { + if (error instanceof UnauthorizedError) { + return []; + } + throw error; } const result = await ctx.runQuery(components.betterAuth.adapter.findMany, { @@ -83,8 +87,12 @@ export const listByTeam = query({ : undefined; userMap.set(userId, { name, email }); } - } catch { - // Graceful degradation: member appears without name/email + } catch (error) { + console.warn( + '[TeamMembers] Failed to fetch user details', + userId, + error, + ); } }), ); From 93c3ca6f180678e18873eaf338e0019ebd156136 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Wed, 4 Mar 2026 11:44:39 +0800 Subject: [PATCH 3/3] test(platform): add tests for getByName and getCurrentMemberContext query handlers --- .../integrations/__tests__/queries.test.ts | 86 ++++++++++++++++ .../convex/members/__tests__/queries.test.ts | 97 ++++++++++++++++++- 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/services/platform/convex/integrations/__tests__/queries.test.ts b/services/platform/convex/integrations/__tests__/queries.test.ts index 6ad1801fce..ab246bb579 100644 --- a/services/platform/convex/integrations/__tests__/queries.test.ts +++ b/services/platform/convex/integrations/__tests__/queries.test.ts @@ -83,11 +83,13 @@ const { getAuthUserIdentity, getOrganizationMember } = await import('../../lib/rls'); const { UnauthorizedError } = await import('../../lib/rls/errors'); const { getIntegration } = await import('../get_integration'); +const { getIntegrationByName } = await import('../get_integration_by_name'); const { listIntegrations } = await import('../list_integrations'); const mockedGetAuthUser = vi.mocked(getAuthUserIdentity); const mockedGetOrgMember = vi.mocked(getOrganizationMember); const mockedGetIntegration = vi.mocked(getIntegration); +const mockedGetIntegrationByName = vi.mocked(getIntegrationByName); const mockedListIntegrations = vi.mocked(listIntegrations); function createMockCtx() { @@ -192,6 +194,90 @@ describe('get handler', () => { }); }); +describe('getByName handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'My Integration', + }); + + expect(result).toBeNull(); + }); + + it('returns null when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'My Integration', + }); + + expect(result).toBeNull(); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + await expect( + handler(ctx, { organizationId: 'org_1', name: 'My Integration' }), + ).rejects.toThrow('DB failure'); + }); + + it('returns null when integration not found', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({} as never); + mockedGetIntegrationByName.mockResolvedValue(null); + const ctx = createMockCtx(); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'Nonexistent', + }); + + expect(result).toBeNull(); + }); + + it('returns integration with icon URL on success', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1' }); + mockedGetOrgMember.mockResolvedValue({} as never); + const doc = makeIntegrationDoc({ iconStorageId: 'storage_icon' }); + mockedGetIntegrationByName.mockResolvedValue(doc); + const ctx = createMockCtx(); + ctx.storage.getUrl.mockResolvedValue('https://storage.example.com/icon'); + const { getByName } = await import('../queries'); + const handler = (getByName as unknown as { handler: Function }).handler; + + const result = await handler(ctx, { + organizationId: 'org_1', + name: 'Test Integration', + }); + + expect(result).toMatchObject({ + _id: 'int_1', + iconUrl: 'https://storage.example.com/icon', + }); + }); +}); + describe('list handler', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/services/platform/convex/members/__tests__/queries.test.ts b/services/platform/convex/members/__tests__/queries.test.ts index ae4b11870f..61a3836d97 100644 --- a/services/platform/convex/members/__tests__/queries.test.ts +++ b/services/platform/convex/members/__tests__/queries.test.ts @@ -75,7 +75,8 @@ vi.mock('../../_generated/server', async (importOriginal) => { const { getAuthUserIdentity, getOrganizationMember } = await import('../../lib/rls'); -const { UnauthorizedError } = await import('../../lib/rls/errors'); +const { UnauthenticatedError, UnauthorizedError } = + await import('../../lib/rls/errors'); const { getMyTeamsHandler, approxCountMyTeamsHandler, @@ -93,6 +94,100 @@ function createMockCtx() { }; } +describe('getCurrentMemberContext handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function getHandler() { + const { getCurrentMemberContext } = await import('../queries'); + return (getCurrentMemberContext as unknown as { handler: Function }) + .handler; + } + + it('throws UnauthenticatedError when not authenticated', async () => { + mockedGetAuthUser.mockResolvedValue(null); + const ctx = createMockCtx(); + const handler = await getHandler(); + + await expect( + handler(ctx, { organizationId: 'org_1' }), + ).rejects.toBeInstanceOf(UnauthenticatedError); + }); + + it('returns null when unauthorized', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Alice' }); + mockedGetOrgMember.mockRejectedValue(new UnauthorizedError()); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toBeNull(); + }); + + it('re-throws non-authorization errors from getOrganizationMember', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Alice' }); + mockedGetOrgMember.mockRejectedValue(new Error('DB failure')); + const ctx = createMockCtx(); + const handler = await getHandler(); + + await expect(handler(ctx, { organizationId: 'org_1' })).rejects.toThrow( + 'DB failure', + ); + }); + + it('returns member context on success', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Alice' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_1', + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + createdAt: 1000, + }); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual({ + memberId: 'om_1', + organizationId: 'org_1', + userId: 'user_1', + role: 'admin', + createdAt: 1000, + displayName: 'Alice', + isAdmin: true, + }); + }); + + it('defaults to member role for invalid roles', async () => { + mockedGetAuthUser.mockResolvedValue({ userId: 'user_1', name: 'Bob' }); + mockedGetOrgMember.mockResolvedValue({ + _id: 'om_2', + organizationId: 'org_1', + userId: 'user_1', + role: 'superadmin', + createdAt: 2000, + }); + const ctx = createMockCtx(); + const handler = await getHandler(); + + const result = await handler(ctx, { organizationId: 'org_1' }); + + expect(result).toEqual({ + memberId: 'om_2', + organizationId: 'org_1', + userId: 'user_1', + role: 'member', + createdAt: 2000, + displayName: 'Bob', + isAdmin: false, + }); + }); +}); + describe('getMyTeamsHandler', () => { beforeEach(() => { vi.clearAllMocks();