feat(slack): autoFlagPreExistingSlackAccess helper

Best-effort lookup of an activating member's email in the Slack
workspace. On a hit, flips slackInvited:true and stamps slackInvitedAt
without sending a fresh invite. Races against a 3s timeout and swallows
all errors so activation never blocks on Slack.

- Promotes SlackService.findUserByEmail from private to public so the
  helper can call it without a wrapper.
- New activity-log action: slack_access_auto_detected (actor = subject).
- Idempotent: short-circuits when slackInvited is already true.

Callers wired in next commit.
This commit is contained in:
Jennie Robinson Faber 2026-04-29 12:13:59 +01:00
parent 2f6a92ac61
commit b1d8cb1966
4 changed files with 263 additions and 1 deletions

View file

@ -0,0 +1,134 @@
// 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)')
})