|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 5 | + |
| 6 | +const { mockGetInvitationJoinPreview, mockListPendingInvitationsForEmail } = vi.hoisted(() => ({ |
| 7 | + mockGetInvitationJoinPreview: vi.fn(), |
| 8 | + mockListPendingInvitationsForEmail: vi.fn(), |
| 9 | +})) |
| 10 | + |
| 11 | +vi.mock('@/lib/invitations/core', () => ({ |
| 12 | + getInvitationJoinPreview: mockGetInvitationJoinPreview, |
| 13 | + listPendingInvitationsForEmail: mockListPendingInvitationsForEmail, |
| 14 | +})) |
| 15 | + |
| 16 | +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' |
| 17 | + |
| 18 | +const USER_ID = 'user-1' |
| 19 | +const EMAIL = 'invitee@example.com' |
| 20 | + |
| 21 | +function invitation(id: string) { |
| 22 | + return { |
| 23 | + id, |
| 24 | + kind: 'organization' as const, |
| 25 | + email: EMAIL, |
| 26 | + organizationId: 'org-1', |
| 27 | + organizationName: 'Org', |
| 28 | + membershipIntent: 'member', |
| 29 | + role: 'member', |
| 30 | + status: 'pending', |
| 31 | + expiresAt: new Date('2026-02-01T00:00:00.000Z'), |
| 32 | + createdAt: new Date('2026-01-01T00:00:00.000Z'), |
| 33 | + inviterName: 'Ada', |
| 34 | + inviterEmail: 'ada@example.com', |
| 35 | + grants: [{ workspaceId: 'ws-1', workspaceName: 'WS', permission: 'read' }], |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +describe('listPendingInvitationsForViewer', () => { |
| 40 | + beforeEach(() => { |
| 41 | + vi.clearAllMocks() |
| 42 | + }) |
| 43 | + |
| 44 | + it('pairs each row with its own preview, in order', async () => { |
| 45 | + mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b', 'c'].map(invitation)) |
| 46 | + mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => ({ for: inv.id })) |
| 47 | + |
| 48 | + const rows = await listPendingInvitationsForViewer(USER_ID, EMAIL) |
| 49 | + |
| 50 | + expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']) |
| 51 | + expect(rows.map((r) => r.joinPreview)).toEqual([{ for: 'a' }, { for: 'b' }, { for: 'c' }]) |
| 52 | + }) |
| 53 | + |
| 54 | + /** |
| 55 | + * The preview is disclosure-only, so one failing row must degrade to `null` rather than |
| 56 | + * hiding the invitation or failing the batch — which is what makes the bounded mapper |
| 57 | + * safe, since it fails all-or-nothing on a throwing mapper. |
| 58 | + */ |
| 59 | + it('degrades a failing preview to null without dropping the invitation', async () => { |
| 60 | + mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b'].map(invitation)) |
| 61 | + mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => { |
| 62 | + if (inv.id === 'a') throw new Error('preview blew up') |
| 63 | + return { for: inv.id } |
| 64 | + }) |
| 65 | + |
| 66 | + const rows = await listPendingInvitationsForViewer(USER_ID, EMAIL) |
| 67 | + |
| 68 | + expect(rows).toHaveLength(2) |
| 69 | + expect(rows[0].joinPreview).toBeNull() |
| 70 | + expect(rows[1].joinPreview).toEqual({ for: 'b' }) |
| 71 | + }) |
| 72 | + |
| 73 | + /** |
| 74 | + * This runs inside the sidebar prefetch on every workspace page render, and each preview |
| 75 | + * issues several queries — so the fan-out stays bounded rather than holding one pooled |
| 76 | + * connection per pending invitation. |
| 77 | + */ |
| 78 | + it('bounds how many previews run at once', async () => { |
| 79 | + mockListPendingInvitationsForEmail.mockResolvedValue( |
| 80 | + Array.from({ length: 12 }, (_, i) => invitation(`inv-${i}`)) |
| 81 | + ) |
| 82 | + let inFlight = 0 |
| 83 | + let peak = 0 |
| 84 | + mockGetInvitationJoinPreview.mockImplementation(async () => { |
| 85 | + inFlight++ |
| 86 | + peak = Math.max(peak, inFlight) |
| 87 | + await Promise.resolve() |
| 88 | + inFlight-- |
| 89 | + return null |
| 90 | + }) |
| 91 | + |
| 92 | + await listPendingInvitationsForViewer(USER_ID, EMAIL) |
| 93 | + |
| 94 | + expect(mockGetInvitationJoinPreview).toHaveBeenCalledTimes(12) |
| 95 | + expect(peak).toBeGreaterThan(1) |
| 96 | + expect(peak).toBeLessThanOrEqual(4) |
| 97 | + }) |
| 98 | + |
| 99 | + it('serializes dates to the wire shape', async () => { |
| 100 | + mockListPendingInvitationsForEmail.mockResolvedValue([invitation('a')]) |
| 101 | + mockGetInvitationJoinPreview.mockResolvedValue(null) |
| 102 | + |
| 103 | + const [row] = await listPendingInvitationsForViewer(USER_ID, EMAIL) |
| 104 | + |
| 105 | + expect(row.expiresAt).toBe('2026-02-01T00:00:00.000Z') |
| 106 | + expect(row.createdAt).toBe('2026-01-01T00:00:00.000Z') |
| 107 | + }) |
| 108 | +}) |
0 commit comments