// Spec: docs/specs/wave-based-slack-onboarding.md // Test plan: docs/specs/wave-based-slack-onboarding-tests.md §2 // // SCAFFOLD: `describe.skip` until the helper at server/utils/slackAccess.js // is created. Unskip during implementation (TDD). import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' vi.mock('../../../server/models/member.js', () => ({ default: { findByIdAndUpdate: vi.fn().mockResolvedValue(null) } })) vi.mock('../../../server/utils/slack.ts', () => ({ getSlackService: vi.fn() })) // `logActivity` is stubbed globally in tests/server/setup.js describe('autoFlagPreExistingSlackAccess (server/utils/slackAccess.js)', () => { let autoFlagPreExistingSlackAccess let Member let getSlackService let findUserByEmail beforeEach(async () => { vi.clearAllMocks() vi.useFakeTimers() findUserByEmail = vi.fn() getSlackService = (await import('../../../server/utils/slack.ts')).getSlackService getSlackService.mockReturnValue({ findUserByEmail }) Member = (await import('../../../server/models/member.js')).default autoFlagPreExistingSlackAccess = ( await import('../../../server/utils/slackAccess.js') ).autoFlagPreExistingSlackAccess globalThis.logActivity.mockClear?.() }) afterEach(() => { vi.useRealTimers() }) const baseMember = () => ({ _id: 'member-abc', email: 'pat@example.com', name: 'Pat Example', slackInvited: false }) it('flags member when Slack lookup finds the email (2.1)', async () => { findUserByEmail.mockResolvedValue('U123') await autoFlagPreExistingSlackAccess(baseMember()) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-abc', expect.objectContaining({ slackInvited: true, slackInvitedAt: expect.any(Date), slackUserId: 'U123' }), expect.objectContaining({ runValidators: false }) ) expect(globalThis.logActivity).toHaveBeenCalledWith( 'member-abc', 'slack_access_auto_detected', expect.any(Object), expect.any(Object) ) }) it('no-ops when lookup returns no match (2.2)', async () => { findUserByEmail.mockResolvedValue(null) await autoFlagPreExistingSlackAccess(baseMember()) expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() expect(globalThis.logActivity).not.toHaveBeenCalled() }) it('returns silently when Slack service is not configured (2.3)', async () => { getSlackService.mockReturnValue(null) await expect( autoFlagPreExistingSlackAccess(baseMember()) ).resolves.not.toThrow() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) it('swallows Slack API errors and does not throw to caller (2.4)', async () => { findUserByEmail.mockRejectedValue(new Error('slack down')) await expect( autoFlagPreExistingSlackAccess(baseMember()) ).resolves.not.toThrow() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) it('returns within timeout if Slack lookup hangs (2.5)', async () => { findUserByEmail.mockImplementation(() => new Promise(() => { /* never resolves */ })) const promise = autoFlagPreExistingSlackAccess(baseMember()) await vi.advanceTimersByTimeAsync(3500) await expect(promise).resolves.not.toThrow() expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) it('uses findByIdAndUpdate with runValidators:false, never member.save (2.6)', async () => { findUserByEmail.mockResolvedValue('U999') const member = baseMember() member.save = vi.fn() // sentinel — must not be called await autoFlagPreExistingSlackAccess(member) expect(member.save).not.toHaveBeenCalled() const opts = Member.findByIdAndUpdate.mock.calls.at(-1)?.[2] expect(opts).toMatchObject({ runValidators: false }) }) it('writes activity log entry distinct from manual mark (2.7)', async () => { findUserByEmail.mockResolvedValue('U123') await autoFlagPreExistingSlackAccess(baseMember()) const [, action] = globalThis.logActivity.mock.calls.at(-1) expect(action).toBe('slack_access_auto_detected') expect(action).not.toBe('slack_invited_manually') }) it.todo('idempotent: does not re-stamp slackInvitedAt on already-flagged member (2.8)') })