diff --git a/app/pages/admin/members/[id].vue b/app/pages/admin/members/[id].vue index 9a0acad..4d8be5d 100644 --- a/app/pages/admin/members/[id].vue +++ b/app/pages/admin/members/[id].vue @@ -39,11 +39,11 @@
- +
- +
@@ -106,19 +106,8 @@
Slack invite
-
- Invited {{ formatDate(member.slackInvitedAt) }} -
-
- Not yet invited - +
+ {{ member.slackInvited ? "Invited" : "Pending" }}
@@ -166,6 +155,12 @@ {{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
+
+
Slack status
+
+ {{ member.slackInviteStatus || 'none' }} +
+
@@ -361,31 +356,12 @@ const hasBoardEngaged = computed(() => { ) }) -const markingSlackInvited = ref(false) - -async function markSlackInvited() { - if (!member.value || markingSlackInvited.value) return - markingSlackInvited.value = true - try { - const res = await $fetch( - `/api/admin/members/${route.params.id}/slack-status`, - { - method: "PATCH", - body: { slackInvited: true }, - }, - ) - member.value = { ...member.value, ...res.member } - toast.add({ title: "Marked as Slack invited", color: "success" }) - } catch (err) { - toast.add({ - title: "Failed to mark Slack invited", - description: err.data?.statusMessage || err.message, - color: "error", - }) - } finally { - markingSlackInvited.value = false - } -} +const slackStatusClass = computed(() => { + const status = member.value?.slackInviteStatus + if (status === 'joined') return 'status-ok' + if (status === 'invited') return 'status-dim' + return 'status-dim' +}) // Activity log const activityEntries = ref([]) @@ -577,32 +553,6 @@ onMounted(loadActivity) word-break: break-all; } -.meta-action { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.link-btn { - background: none; - border: none; - color: var(--candle); - cursor: pointer; - font-family: "Commit Mono", monospace; - font-size: 11px; - padding: 2px 6px; -} - -.link-btn:hover { - text-decoration: underline; -} - -.link-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - .mono { font-family: "Commit Mono", monospace; font-size: 11px; diff --git a/app/pages/admin/members/index.vue b/app/pages/admin/members/index.vue index 4e8f28d..48ca472 100644 --- a/app/pages/admin/members/index.vue +++ b/app/pages/admin/members/index.vue @@ -124,11 +124,8 @@ - - Invited {{ formatDate(member.slackInvitedAt) }} - - - Not yet invited + + {{ member.slackInvited ? "Invited" : "Pending" }} @@ -138,12 +135,8 @@ View - @@ -836,24 +829,8 @@ const submitInvites = async () => { }; // --- Existing actions --- -const markSlackInvited = async (member) => { - try { - const res = await $fetch( - `/api/admin/members/${member._id}/slack-status`, - { - method: "PATCH", - body: { slackInvited: true }, - }, - ); - Object.assign(member, res.member); - toast.add({ title: "Marked as Slack invited", color: "success" }); - } catch (err) { - toast.add({ - title: "Failed to mark Slack invited", - description: err.data?.statusMessage || err.message, - color: "error", - }); - } +const sendSlackInvite = (member) => { + console.log("Send Slack invite to:", member.email); }; // --- Edit Member --- diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue index 26c0ad9..eeba869 100644 --- a/app/pages/member/dashboard.vue +++ b/app/pages/member/dashboard.vue @@ -38,10 +38,6 @@ ${{ memberData?.contributionAmount ?? 0 }} CAD/mo -

- Slack workspace access is part of your membership. Your invitation - typically arrives within 2–3 weeks of joining. -

@@ -228,10 +224,6 @@ const { isActive, statusConfig, isPendingPayment, canPeerSupport } = const route = useRoute(); const isNewSignup = computed(() => route.query.welcome === "1"); -const showSlackComingNote = computed( - () => - memberData.value?.status === "active" && !memberData.value?.slackInvited, -); const welcomeTitle = computed(() => { const name = memberData.value?.name || ""; return isNewSignup.value @@ -476,13 +468,6 @@ useHead({ margin-top: 8px; } -.slack-coming-note { - margin-top: 12px; - font-size: 12px; - color: var(--text-dim); - line-height: 1.65; -} - .content-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/e2e/wave-slack-onboarding.spec.js b/e2e/wave-slack-onboarding.spec.js deleted file mode 100644 index 0c3af03..0000000 --- a/e2e/wave-slack-onboarding.spec.js +++ /dev/null @@ -1,103 +0,0 @@ -// Spec: docs/specs/wave-based-slack-onboarding.md -// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7 -// -// SCAFFOLD: every test is `.skip`ed and contains a TODO. As the UI lands, -// unskip and fill in selectors / fixtures. -// -// These cover the rendered behavior that unit tests can't: dashboard line -// visibility under different member statuses, and the admin-list "Mark as -// Slack invited" button + status display. - -import { test, expect } from './helpers/fixtures.js' - -test.describe('Member dashboard — Slack-coming note (§7)', () => { - test.skip('shows note for active member without Slack (7.1)', async () => { - // TODO: seed a member { status: 'active', slackInvited: false }, sign in, - // navigate to /member/dashboard, assert the one-liner is visible: - // await expect(page.getByText(/within 2.3 weeks/i)).toBeVisible() - }) - - test.skip('hides note once slackInvited:true (7.2)', async () => { - // TODO: same as 7.1 but with slackInvited:true; assert text not present. - }) - - test.skip('hides note for pending_payment member (7.3)', async () => { - // TODO: pending_payment + slackInvited:false; assert text not present. - }) - - test.skip('hides note for suspended/cancelled/guest (7.4)', async () => { - // TODO: parameterize across statuses { suspended, cancelled, guest }. - }) - - test.skip('copy contains no wave/cohort/batch language (7.5)', async ({ adminPage }) => { - await adminPage.goto('/member/dashboard') - const html = await adminPage.content() - expect(html).not.toMatch(/\bwave\b/i) - expect(html).not.toMatch(/\bcohort\b/i) - expect(html).not.toMatch(/\bbatch\b/i) - }) - - test.skip('renders as plain text — no banner / modal / callout styling (7.6)', async () => { - // TODO: assert the note's container is not a UAlert / modal / heavy callout - // (e.g. no .alert, no role="dialog" wrapper). - }) - - test.skip('SSR renders without auth — note absent (7.7)', async ({ browser }) => { - const context = await browser.newContext() - const page = await context.newPage() - const response = await page.goto('/member/dashboard') - const ssrHtml = await response.text() - expect(ssrHtml).not.toMatch(/within 2.3 weeks/i) - await context.close() - }) - - test.skip('copy matches approved wording (7.8)', async () => { - // TODO: replace with the final approved string once the Open Question is resolved. - }) -}) - -test.describe('Admin members — Slack-invited control (§6)', () => { - test.skip('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => { - await adminPage.goto('/admin/members') - // TODO: locate a row for a member with slackInvited:false and assert the - // button is visible. - // await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible() - }) - - test.skip('replaces button with "Invited " once flipped (6.2)', async () => { - // TODO: click the button on a row; assert button is gone, date string visible. - }) - - test.skip('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => { - // TODO: spy on network for /api/admin/members/*/slack-status; click button; - // assert single PATCH, success, no full-page reload. - }) - - test.skip('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => { - await adminPage.goto('/admin/members') - // TODO: - // await expect(adminPage.getByText(/Not yet invited/i).first()).toBeVisible() - // const html = await adminPage.content() - // expect(html).not.toMatch(/Slack:\s*Pending/i) - }) - - test.skip('member detail page mirrors list controls (6.6)', async () => { - // TODO: navigate to /admin/members/; assert button + date display. - }) - - test.skip('no UI references slackInviteStatus (6.7)', async ({ adminPage }) => { - // Static assertion of rendered HTML — no leftover badge labels keyed off the dropped field. - await adminPage.goto('/admin/members') - const html = await adminPage.content() - expect(html).not.toMatch(/slackInviteStatus/) - }) - - test.skip('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async () => { - // TODO: mock the endpoint to return 500; assert the row stays in - // "Not yet invited" state. - }) - - test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => { - // TODO: dependent on Open Question — wire up if implemented. - }) -}) diff --git a/server/api/admin/members/[id]/slack-status.patch.js b/server/api/admin/members/[id]/slack-status.patch.js deleted file mode 100644 index 6c2ca6c..0000000 --- a/server/api/admin/members/[id]/slack-status.patch.js +++ /dev/null @@ -1,35 +0,0 @@ -import Member from '../../../../models/member.js' -import { connectDB } from '../../../../utils/mongoose.js' -import { validateBody } from '../../../../utils/validateBody.js' -import { adminSlackStatusSchema } from '../../../../utils/schemas.js' - -export default defineEventHandler(async (event) => { - const admin = await requireAdmin(event) - await validateBody(event, adminSlackStatusSchema) - await connectDB() - - const memberId = getRouterParam(event, 'id') - - const existing = await Member.findById(memberId) - if (!existing) { - throw createError({ - statusCode: 404, - statusMessage: 'Member not found.' - }) - } - - // Idempotent: if already invited, no-op (preserve original slackInvitedAt, no log). - if (existing.slackInvited === true) { - return { success: true, member: existing } - } - - const member = await Member.findByIdAndUpdate( - memberId, - { slackInvited: true, slackInvitedAt: new Date() }, - { new: true, runValidators: false } - ) - - logActivity(memberId, 'slack_invited_manually', {}, { performedBy: admin._id }) - - return { success: true, member } -}) diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index 577e264..c4eb977 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -8,6 +8,77 @@ import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTra import { sendWelcomeEmail } from '../../utils/resend.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js' +// Function to invite member to Slack +async function inviteToSlack(member) { + try { + const slackService = getSlackService() + if (!slackService) { + console.warn('Slack service not configured, skipping invitation') + return + } + + console.log(`Processing Slack invitation for ${member.email}...`) + + const inviteResult = await slackService.inviteUserToSlack( + member.email, + member.name + ) + + if (inviteResult.success) { + const update = {} + if (inviteResult.status === 'existing_user_added_to_channel' || + inviteResult.status === 'user_already_in_channel' || + inviteResult.status === 'new_user_invited_to_workspace') { + update.slackInviteStatus = 'sent' + update.slackUserId = inviteResult.userId + update.slackInvited = true + } else { + update.slackInviteStatus = 'pending' + update.slackInvited = false + } + await Member.findByIdAndUpdate( + member._id, + { $set: update }, + { runValidators: false } + ) + + // Send notification to vetting channel + await slackService.notifyNewMember( + member.name, + member.email, + member.circle, + member.contributionAmount, + inviteResult.status + ) + + console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`) + } else { + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'failed' } }, + { runValidators: false } + ) + + console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`) + // Don't throw error - subscription creation should still succeed + } + } catch (error) { + console.error('Error during Slack invitation process:', error) + + try { + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'failed' } }, + { runValidators: false } + ) + } catch (saveError) { + console.error('Failed to update member Slack status:', saveError) + } + + // Don't throw error - subscription creation should still succeed + } +} + export default defineEventHandler(async (event) => { try { // Membership signup completes subscription before email verify; allow the @@ -38,21 +109,7 @@ export default defineEventHandler(async (event) => { logActivity(member._id, 'subscription_created', { amount: body.contributionAmount }) - await autoFlagPreExistingSlackAccess(member) - try { - const slackService = getSlackService() - if (slackService) { - await slackService.notifyNewMember( - member.name, - member.email, - member.circle, - member.contributionAmount, - 'manual_invitation_required' - ) - } - } catch (err) { - console.error('[slack] notifyNewMember failed:', err) - } + await inviteToSlack(member) if (isFirstActivation) await sendWelcomeEmail(member) return { @@ -150,21 +207,7 @@ export default defineEventHandler(async (event) => { console.error('[payments] initial charge log failed, will be picked up by reconciliation:', err?.message || err) } - await autoFlagPreExistingSlackAccess(member) - try { - const slackService = getSlackService() - if (slackService) { - await slackService.notifyNewMember( - member.name, - member.email, - member.circle, - member.contributionAmount, - 'manual_invitation_required' - ) - } - } catch (err) { - console.error('[slack] notifyNewMember failed:', err) - } + await inviteToSlack(member) if (isFirstActivation) await sendWelcomeEmail(member) return { diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 84d5db6..384ae8f 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -87,7 +87,6 @@ export default defineEventHandler(async (event) => { // For free tier, redirect to welcome if (body.contributionAmount === 0) { - await autoFlagPreExistingSlackAccess(member) return { success: true, requiresPayment: false, diff --git a/server/api/members/create.post.js b/server/api/members/create.post.js index 9b78831..53827b0 100644 --- a/server/api/members/create.post.js +++ b/server/api/members/create.post.js @@ -7,6 +7,80 @@ import { memberCreateSchema } from '../../utils/schemas.js' import { sendWelcomeEmail } from '../../utils/resend.js' import { assignMemberNumber } from '../../utils/memberNumber.js' +// Function to invite member to Slack +async function inviteToSlack(member) { + try { + const slackService = getSlackService() + if (!slackService) { + console.warn('Slack service not configured, skipping invitation') + return + } + + console.warn(`Processing Slack invitation for member`) + + const inviteResult = await slackService.inviteUserToSlack( + member.email, + member.name + ) + + if (inviteResult.success) { + // Update member record based on the actual result + if (inviteResult.status === 'existing_user_added_to_channel' || + inviteResult.status === 'user_already_in_channel' || + inviteResult.status === 'new_user_invited_to_workspace') { + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'sent', slackUserId: inviteResult.userId, slackInvited: true } }, + { runValidators: false } + ) + } else { + // Manual invitation required + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'pending', slackInvited: false } }, + { runValidators: false } + ) + } + + // Send notification to vetting channel + await slackService.notifyNewMember( + member.name, + member.email, + member.circle, + member.contributionAmount, + inviteResult.status + ) + + console.warn(`Slack invitation processed: ${inviteResult.status}`) + } else { + // Update member record to reflect failed invitation + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'failed' } }, + { runValidators: false } + ) + + console.error(`Failed to process Slack invitation: ${inviteResult.error}`) + // Don't throw error - member creation should still succeed + } + } catch (error) { + console.error('Error during Slack invitation process:', error) + + // Update member record to reflect failed invitation + try { + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'failed' } }, + { runValidators: false } + ) + } catch (saveError) { + console.error('Failed to update member Slack status:', saveError) + } + + // Don't throw error - member creation should still succeed + } +} + export default defineEventHandler(async (event) => { // Ensure database is connected await connectDB() @@ -33,22 +107,8 @@ export default defineEventHandler(async (event) => { circle: member.circle }, { timestamp: member.createdAt }) - // Auto-flag pre-existing Slack workspace members; admin manually invites the rest. - await autoFlagPreExistingSlackAccess(member) - try { - const slackService = getSlackService() - if (slackService) { - await slackService.notifyNewMember( - member.name, - member.email, - member.circle, - member.contributionAmount, - 'manual_invitation_required' - ) - } - } catch (err) { - console.error('[slack] notifyNewMember failed:', err) - } + // Send Slack invitation for new members + await inviteToSlack(member) // Send welcome email (non-blocking) try { diff --git a/server/models/member.js b/server/models/member.js index 0aa2b3d..3034eb0 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -59,7 +59,11 @@ const memberSchema = new mongoose.Schema({ nextBillingDate: Date, lastCancelledAt: Date, slackInvited: { type: Boolean, default: false }, - slackInvitedAt: { type: Date }, + slackInviteStatus: { + type: String, + enum: ["pending", "sent", "failed", "accepted", "joined"], + default: "pending", + }, slackUserId: String, // Profile fields diff --git a/server/plugins/check-slack-joins.js b/server/plugins/check-slack-joins.js new file mode 100644 index 0000000..9f53ff9 --- /dev/null +++ b/server/plugins/check-slack-joins.js @@ -0,0 +1,29 @@ +// server/plugins/check-slack-joins.js +import { checkSlackJoins } from '../utils/checkSlackJoins.js' + +const INTERVAL_MS = 3600000 // 1 hour + +export default defineNitroPlugin(() => { + // Don't run in test environment + if (process.env.NODE_ENV === 'test') return + + const config = useRuntimeConfig() + const token = config.slackBotToken + + if (!token) { + console.warn('[check-slack-joins] No Slack bot token configured, skipping background job') + return + } + + async function run() { + try { + await checkSlackJoins(token) + } catch (err) { + console.error('[check-slack-joins] Unhandled error:', err.message || err) + } + } + + // Run immediately on server start, then every hour + run() + setInterval(run, INTERVAL_MS) +}) diff --git a/server/utils/adminAlerts.js b/server/utils/adminAlerts.js index 60dd9af..c8de63f 100644 --- a/server/utils/adminAlerts.js +++ b/server/utils/adminAlerts.js @@ -17,6 +17,7 @@ export const ALERT_THRESHOLDS = { // Single source of truth for alert presentation. Used by detectors AND the // dismissed-list endpoint (which has no access to a detector run's output). export const ALERT_METADATA = { + slack_invite_failed: { title: 'Slack invites failed', severity: 'critical' }, no_slack_handle_week: { title: 'Active members without a Slack handle', severity: 'attention' }, stuck_pending_payment: { title: 'Members stuck in pending payment', severity: 'attention' }, member_suspended: { title: 'Suspended members', severity: 'attention' }, @@ -61,6 +62,18 @@ function memberItem(member, sublabel) { } } +export async function detectSlackInviteFailed() { + await connectDB() + const members = await Member + .find({ slackInviteStatus: 'failed' }) + .select('name email') + .lean() + return { + ...alertShell('slack_invite_failed'), + items: members.map((m) => memberItem(m)) + } +} + export async function detectNoSlackHandleAfterWeek() { await connectDB() const cutoff = daysAgo(ALERT_THRESHOLDS.NO_SLACK_DAYS) @@ -244,6 +257,7 @@ export async function detectPendingTagSuggestions() { } const DETECTORS = [ + detectSlackInviteFailed, detectNoSlackHandleAfterWeek, detectStuckPendingPayment, detectSuspendedMembers, diff --git a/server/_archive/utils/checkSlackJoins.js b/server/utils/checkSlackJoins.js similarity index 100% rename from server/_archive/utils/checkSlackJoins.js rename to server/utils/checkSlackJoins.js diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 7345224..cb75944 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -314,10 +314,6 @@ export const adminRoleUpdateSchema = z.object({ role: z.enum(['admin', 'member']) }) -export const adminSlackStatusSchema = z.object({ - slackInvited: z.literal(true) -}).strict() - export const bulkMemberImportSchema = z.object({ members: z.array(z.object({ name: z.string().min(1).max(200), diff --git a/server/utils/slack.ts b/server/utils/slack.ts index 2216e21..f1fd674 100644 --- a/server/utils/slack.ts +++ b/server/utils/slack.ts @@ -9,10 +9,102 @@ export class SlackService { this.vettingChannelId = vettingChannelId; } + /** + * Invite user to workspace and channel (using proper admin and conversation scopes) + */ + async inviteUserToSlack( + email: string, + realName: string, + ): Promise<{ + success: boolean; + userId?: string; + status?: string; + error?: string; + }> { + try { + // First, check if user already exists in workspace + const existingUser = await this.findUserByEmail(email); + + if (existingUser) { + // User exists, invite them to the vetting channel + try { + await this.client.conversations.invite({ + channel: this.vettingChannelId, + users: existingUser, + }); + + console.log( + `Successfully invited existing user ${email} to vetting channel`, + ); + return { + success: true, + userId: existingUser, + status: "existing_user_added_to_channel", + }; + } catch (error: any) { + if (error.data?.error === "already_in_channel") { + return { + success: true, + userId: existingUser, + status: "user_already_in_channel", + }; + } + throw error; + } + } + + // User doesn't exist, try to invite to workspace using admin API + try { + const inviteResponse = await this.client.admin.users.invite({ + email: email, + real_name: realName, + channel_ids: [this.vettingChannelId], + is_restricted: true, // Single-channel guest + is_ultra_restricted: false, + }); + + if (inviteResponse.ok && inviteResponse.user) { + console.log( + `Successfully invited ${email} to workspace as single-channel guest`, + ); + return { + success: true, + userId: inviteResponse.user.id, + status: "new_user_invited_to_workspace", + }; + } else { + throw new Error(`Admin invite failed: ${inviteResponse.error}`); + } + } catch (adminError: any) { + console.log( + `Admin API not available or failed: ${ + adminError.data?.error || adminError.message + }`, + ); + + // Fall back to manual process + return { + success: true, + status: "manual_invitation_required", + error: `Admin API unavailable: ${ + adminError.data?.error || adminError.message + }`, + }; + } + } catch (error: any) { + console.error(`Failed to process invitation for ${email}:`, error); + + return { + success: false, + error: error.data?.error || error.message || "Unknown error occurred", + }; + } + } + /** * Find user in workspace by email */ - async findUserByEmail(email: string): Promise { + private async findUserByEmail(email: string): Promise { try { const response = await this.client.users.lookupByEmail({ email }); return response.user?.id || null; diff --git a/server/utils/slackAccess.js b/server/utils/slackAccess.js deleted file mode 100644 index 885ae12..0000000 --- a/server/utils/slackAccess.js +++ /dev/null @@ -1,61 +0,0 @@ -// Spec: docs/specs/wave-based-slack-onboarding.md -// -// Auto-detect existing Slack workspace membership for a Ghost Guild member -// and flag them as `slackInvited` without sending a duplicate invite. -// -// Contract: -// - Caller awaits this helper, but it never throws — all errors swallowed. -// - Internal lookup races against a 3s timeout (resolves null on hang). -// - No-op if Slack service isn't configured, lookup misses, or member is -// already flagged. - -import Member from '../models/member.js' -import { getSlackService } from './slack.ts' - -const LOOKUP_TIMEOUT_MS = 3000 - -export async function autoFlagPreExistingSlackAccess(member) { - try { - if (!member || !member._id || !member.email) return - if (member.slackInvited === true) return - - const slackService = getSlackService() - if (!slackService) return - - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(null), LOOKUP_TIMEOUT_MS) - }) - - let userId = null - try { - userId = await Promise.race([ - slackService.findUserByEmail(member.email), - timeoutPromise - ]) - } catch (err) { - console.error('[slackAccess] findUserByEmail failed:', err) - return - } - - if (!userId) return - - await Member.findByIdAndUpdate( - member._id, - { - slackInvited: true, - slackInvitedAt: new Date(), - slackUserId: userId - }, - { runValidators: false } - ) - - await logActivity( - member._id, - 'slack_access_auto_detected', - { slackUserId: userId }, - { visibility: 'member' } - ) - } catch (err) { - console.error('[slackAccess] autoFlagPreExistingSlackAccess error:', err) - } -} diff --git a/tests/server/api/activation-auto-flag.test.js b/tests/server/api/activation-auto-flag.test.js deleted file mode 100644 index bcf7493..0000000 --- a/tests/server/api/activation-auto-flag.test.js +++ /dev/null @@ -1,211 +0,0 @@ -// Spec: docs/specs/wave-based-slack-onboarding.md -// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §3 + §4 -// -// Verifies that the three self-serve activation paths invoke -// `autoFlagPreExistingSlackAccess`, while the two admin-create paths do not. -// -// `autoFlagPreExistingSlackAccess` is auto-imported by Nitro at runtime; in -// tests it's stubbed as a global in tests/server/setup.js. Tests grab the stub -// off `globalThis` and reset it per-case. - -import { describe, it, expect, vi, beforeEach } from 'vitest' - -import Member from '../../../server/models/member.js' -import { requireAuth } from '../../../server/utils/auth.js' -import { requiresPayment } from '../../../server/config/contributions.js' -import { sendWelcomeEmail } from '../../../server/utils/resend.js' -import { validateBody } from '../../../server/utils/validateBody.js' -// jwt and the auto-flag helper are mocked via globals/vi.mock -import PreRegistration from '../../../server/models/preRegistration.js' -import { createHelcimCustomer } from '../../../server/utils/helcim.js' -import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' -import membersCreateHandler from '../../../server/api/members/create.post.js' -import inviteAcceptHandler from '../../../server/api/invite/accept.post.js' -import { createMockEvent } from '../helpers/createMockEvent.js' - -vi.mock('../../../server/models/member.js', () => { - const mockSave = vi.fn().mockResolvedValue(undefined) - function MockMember(data) { - Object.assign(this, data) - this._id = 'new-member-123' - this.status = data.status || 'pending_payment' - this.save = mockSave - } - MockMember.findOne = vi.fn() - MockMember.findOneAndUpdate = vi.fn() - MockMember.findById = vi.fn() - MockMember.findByIdAndUpdate = vi.fn() - MockMember.create = vi.fn() - MockMember._mockSave = mockSave - return { default: MockMember } -}) -vi.mock('../../../server/models/preRegistration.js', () => ({ - default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() } -})) -vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) -vi.mock('../../../server/utils/auth.js', () => ({ - requireAuth: vi.fn(), - getPaymentBridgeMember: vi.fn().mockResolvedValue(null), - setAuthCookie: vi.fn() -})) -vi.mock('../../../server/utils/slack.ts', () => ({ - getSlackService: vi.fn().mockReturnValue(null) -})) -vi.mock('../../../server/config/contributions.js', () => ({ - requiresPayment: vi.fn(), - getHelcimPlanId: vi.fn() -})) -vi.mock('../../../server/utils/resend.js', () => ({ - sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) -})) -vi.mock('../../../server/utils/helcim.js', () => ({ - createHelcimSubscription: vi.fn(), - generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-1'), - listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]), - createHelcimCustomer: vi.fn() -})) -vi.mock('../../../server/utils/payments.js', () => ({ - upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true, payment: { _id: 'p1' } }) -})) -vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) -vi.mock('../../../server/utils/schemas.js', () => ({ memberCreateSchema: {} })) -vi.mock('../../../server/utils/memberNumber.js', () => ({ - assignMemberNumber: vi.fn().mockResolvedValue(1) -})) -vi.mock('jsonwebtoken', () => { - const verify = vi.fn(() => ({ type: 'prereg-invite', preRegistrationId: 'prereg-1' })) - return { - default: { verify }, - verify - } -}) - -vi.stubGlobal('helcimSubscriptionSchema', {}) -vi.stubGlobal('inviteAcceptSchema', {}) - -const autoFlagStub = globalThis.autoFlagPreExistingSlackAccess - -// --------------------------------------------------------------------------- -// 3.1 — Helcim subscription success calls helper -// --------------------------------------------------------------------------- -describe('POST /api/helcim/subscription — auto-flag wiring (3.1)', () => { - beforeEach(() => { - vi.clearAllMocks() - autoFlagStub.mockClear() - requireAuth.mockResolvedValue(undefined) - requiresPayment.mockReturnValue(false) - }) - - it('calls autoFlagPreExistingSlackAccess after successful free-tier activation', async () => { - const mockMember = { - _id: 'member-1', - email: 'free@example.com', - name: 'Free Tier', - circle: 'community', - contributionAmount: 0, - status: 'active' - } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) - - const event = createMockEvent({ - method: 'POST', - path: '/api/helcim/subscription', - body: { customerId: 'cust-1', contributionAmount: 0, customerCode: 'code-1' } - }) - - await subscriptionHandler(event) - - expect(autoFlagStub).toHaveBeenCalledTimes(1) - expect(autoFlagStub).toHaveBeenCalledWith( - expect.objectContaining({ _id: 'member-1', email: 'free@example.com' }) - ) - }) -}) - -// --------------------------------------------------------------------------- -// 3.2 — Free-tier /api/invite/accept calls helper -// --------------------------------------------------------------------------- -describe('POST /api/invite/accept (free tier) — auto-flag wiring (3.2)', () => { - beforeEach(() => { - vi.clearAllMocks() - autoFlagStub.mockClear() - PreRegistration.findById.mockResolvedValue({ - _id: 'prereg-1', - email: 'invitee@example.com', - status: 'pending' - }) - PreRegistration.findByIdAndUpdate.mockResolvedValue(undefined) - Member.findOne.mockResolvedValue(null) - Member.create.mockImplementation(async (data) => ({ - _id: 'new-member-via-invite', - ...data - })) - // Handler uses Nitro auto-imported validateBody (global), not the explicit import - globalThis.validateBody.mockResolvedValue({ - token: 'tok', - preRegistrationId: 'prereg-1', - name: 'New Invitee', - circle: 'community', - contributionAmount: 0 - }) - }) - - it('calls helper after Member.create on the free-tier branch', async () => { - const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) - - await inviteAcceptHandler(event) - - expect(autoFlagStub).toHaveBeenCalledTimes(1) - expect(autoFlagStub).toHaveBeenCalledWith( - expect.objectContaining({ _id: 'new-member-via-invite', email: 'invitee@example.com' }) - ) - }) - - it('paid-tier branch does NOT call helper (3.3)', async () => { - globalThis.validateBody.mockResolvedValue({ - token: 'tok', - preRegistrationId: 'prereg-1', - name: 'Paid Invitee', - circle: 'community', - contributionAmount: 25 - }) - createHelcimCustomer.mockResolvedValue({ id: 'cust-2', customerCode: 'code-2' }) - - const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) - - await inviteAcceptHandler(event) - - expect(autoFlagStub).not.toHaveBeenCalled() - }) -}) - -// --------------------------------------------------------------------------- -// 3.8 — members/create.post.js calls helper instead of legacy inviteToSlack -// --------------------------------------------------------------------------- -describe('POST /api/members/create — auto-flag wiring (3.8)', () => { - beforeEach(() => { - vi.clearAllMocks() - autoFlagStub.mockClear() - Member.findOne.mockResolvedValue(null) - Member._mockSave.mockResolvedValue(undefined) - sendWelcomeEmail.mockResolvedValue({ success: true }) - validateBody.mockResolvedValue({ - email: 'mc@example.com', - name: 'MC Member', - circle: 'community', - contributionAmount: 0 - }) - }) - - it('calls helper instead of legacy inviteToSlack', async () => { - const event = createMockEvent({ method: 'POST', path: '/api/members/create' }) - - await membersCreateHandler(event) - - expect(autoFlagStub).toHaveBeenCalledTimes(1) - expect(autoFlagStub).toHaveBeenCalledWith( - expect.objectContaining({ email: 'mc@example.com' }) - ) - }) -}) diff --git a/tests/server/api/admin-members-slack-status.test.js b/tests/server/api/admin-members-slack-status.test.js deleted file mode 100644 index 7f06c1f..0000000 --- a/tests/server/api/admin-members-slack-status.test.js +++ /dev/null @@ -1,186 +0,0 @@ -// Spec: docs/specs/wave-based-slack-onboarding.md -// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §5 -// -// SCAFFOLD: `describe.skip` until the route at -// server/api/admin/members/[id]/slack-status.patch.js -// exists. Unskip during implementation (TDD). - -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('../../../server/models/member.js', () => ({ - default: { - findById: vi.fn(), - findByIdAndUpdate: vi.fn() - } -})) - -vi.mock('../../../server/utils/mongoose.js', () => ({ - connectDB: vi.fn() -})) - -vi.mock('../../../server/utils/validateBody.js', () => ({ - validateBody: vi.fn() -})) - -vi.mock('../../../server/utils/schemas.js', () => ({ - adminSlackStatusSchema: {} -})) - -// Slack service must NOT be invoked from this endpoint. -vi.mock('../../../server/utils/slack.ts', () => ({ - getSlackService: vi.fn().mockReturnValue({ - findUserByEmail: vi.fn() - }) -})) - -describe('PATCH /api/admin/members/[id]/slack-status', () => { - let handler - let Member - let validateBody - let getSlackService - - beforeEach(async () => { - vi.clearAllMocks() - - Member = (await import('../../../server/models/member.js')).default - validateBody = (await import('../../../server/utils/validateBody.js')).validateBody - getSlackService = (await import('../../../server/utils/slack.ts')).getSlackService - - handler = ( - await import('../../../server/api/admin/members/[id]/slack-status.patch.js') - ).default - - globalThis.requireAdmin.mockResolvedValue({ - _id: { toString: () => 'admin-1' } - }) - validateBody.mockResolvedValue({ slackInvited: true }) - vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('member-1')) - globalThis.logActivity.mockClear?.() - }) - - function makeEvent() { - return { node: { req: {}, res: {} } } // handler reads via auto-imports + stubs - } - - describe('auth (5.4 / 5.5)', () => { - it('rejects unauthenticated requests with 401', async () => { - globalThis.requireAdmin.mockRejectedValue( - createError({ statusCode: 401, statusMessage: 'Unauthorized' }) - ) - await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 401 }) - }) - - it('rejects non-admin members with 403', async () => { - globalThis.requireAdmin.mockRejectedValue( - createError({ statusCode: 403, statusMessage: 'Forbidden' }) - ) - await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 403 }) - }) - }) - - describe('validation (5.6)', () => { - it('rejects invalid body via validateBody (zod)', async () => { - validateBody.mockRejectedValue( - createError({ statusCode: 400, statusMessage: 'Invalid body' }) - ) - await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 400 }) - }) - }) - - describe('member lookup (5.7)', () => { - it('returns 404 when member does not exist', async () => { - Member.findById.mockResolvedValue(null) - await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 404 }) - }) - }) - - describe('happy path: false → true transition (5.1)', () => { - it('marks member invited, stamps date, logs activity', async () => { - Member.findById.mockResolvedValue({ - _id: 'member-1', - slackInvited: false, - slackInvitedAt: undefined - }) - const updated = { - _id: 'member-1', - slackInvited: true, - slackInvitedAt: new Date('2026-04-29T00:00:00Z') - } - Member.findByIdAndUpdate.mockResolvedValue(updated) - - const result = await handler(makeEvent()) - - expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( - 'member-1', - expect.objectContaining({ - slackInvited: true, - slackInvitedAt: expect.any(Date) - }), - expect.objectContaining({ runValidators: false, new: true }) - ) - expect(globalThis.logActivity).toHaveBeenCalledWith( - 'member-1', - 'slack_invited_manually', - expect.any(Object), - expect.objectContaining({ performedBy: expect.anything() }) - ) - expect(result).toMatchObject({ success: true }) - expect(result.member).toMatchObject({ - slackInvited: true, - slackInvitedAt: expect.any(Date) - }) - }) - - it('uses findByIdAndUpdate with runValidators:false (5.9)', async () => { - Member.findById.mockResolvedValue({ _id: 'member-1', slackInvited: false }) - Member.findByIdAndUpdate.mockResolvedValue({ _id: 'member-1', slackInvited: true }) - - await handler(makeEvent()) - - const opts = Member.findByIdAndUpdate.mock.calls.at(-1)?.[2] - expect(opts).toMatchObject({ runValidators: false }) - }) - }) - - describe('idempotency: true → true (5.2)', () => { - it('preserves original slackInvitedAt and writes no new activity-log entry', async () => { - const original = new Date('2026-01-15T00:00:00Z') - Member.findById.mockResolvedValue({ - _id: 'member-1', - slackInvited: true, - slackInvitedAt: original - }) - - await handler(makeEvent()) - - // Either the endpoint short-circuits with no write, OR the update payload - // carefully omits slackInvitedAt. Both are acceptable; assert outcome: - const updateCall = Member.findByIdAndUpdate.mock.calls.at(-1) - if (updateCall) { - expect(updateCall[1]).not.toHaveProperty('slackInvitedAt') - } - expect(globalThis.logActivity).not.toHaveBeenCalled() - }) - }) - - describe('no Slack API call (5.3)', () => { - it('does not invoke any Slack write API on success', async () => { - Member.findById.mockResolvedValue({ _id: 'member-1', slackInvited: false }) - Member.findByIdAndUpdate.mockResolvedValue({ _id: 'member-1', slackInvited: true }) - - await handler(makeEvent()) - - const slack = getSlackService() - expect(slack.findUserByEmail).not.toHaveBeenCalled() - }) - }) - - describe('source inspection (handler shape)', () => { - // Mirrors the pattern in admin-role-patch.test.js — verifies route - // construction order without booting the handler. - it.todo('calls requireAdmin before validateBody') - it.todo('does not import getSlackService') - }) - - it.todo('PATCH { slackInvited: false } behavior — decided in spec gap #2 (5.11)') -}) diff --git a/tests/server/models/member-slack-fields.test.js b/tests/server/models/member-slack-fields.test.js deleted file mode 100644 index 4fccef4..0000000 --- a/tests/server/models/member-slack-fields.test.js +++ /dev/null @@ -1,69 +0,0 @@ -// Spec: docs/specs/wave-based-slack-onboarding.md -// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §1 -// -// SCAFFOLD: `describe.skip` until the schema migration lands. Tests use the -// schema's path metadata only — no DB connection required. - -import { describe, it, expect } from 'vitest' -import mongoose from 'mongoose' -import Member from '../../../server/models/member.js' - -describe.skip('Member schema — Slack fields (post-migration)', () => { - it('does not define the legacy invite-status field (1.2)', () => { - // Field name reconstructed to avoid the cleanup-sweep grep tripping on a literal. - const legacyField = 'slackInvite' + 'Status' - expect(Member.schema.path(legacyField)).toBeUndefined() - }) - - it('defines slackInvited as Boolean with default false (1.1)', () => { - const path = Member.schema.path('slackInvited') - expect(path).toBeDefined() - expect(path.instance).toBe('Boolean') - expect(path.defaultValue).toBe(false) - }) - - it('defines slackInvitedAt as an optional Date (1.3)', () => { - const path = Member.schema.path('slackInvitedAt') - expect(path).toBeDefined() - expect(path.instance).toBe('Date') - expect(path.isRequired).toBeFalsy() - }) - - it('retains slackUserId as String (1.4)', () => { - const path = Member.schema.path('slackUserId') - expect(path).toBeDefined() - expect(path.instance).toBe('String') - }) - - it('does not auto-stamp slackInvitedAt via pre-save hook (1.5)', () => { - // Constructing a doc and flipping slackInvited should NOT set slackInvitedAt - // — call sites are responsible (project convention). - const doc = new Member({ - email: 't@example.com', - name: 'T', - circle: 'community', - contributionAmount: 0 - }) - doc.slackInvited = true - expect(doc.slackInvitedAt).toBeUndefined() - }) - - it('new member defaults: slackInvited false, slackInvitedAt unset (1.1)', () => { - const doc = new Member({ - email: 'new@example.com', - name: 'New', - circle: 'community', - contributionAmount: 0 - }) - expect(doc.slackInvited).toBe(false) - expect(doc.slackInvitedAt).toBeUndefined() - }) -}) - -// Sanity: import doesn't introduce mongoose connection side-effects. -describe('mongoose import sanity', () => { - it('imports without error', () => { - expect(mongoose).toBeDefined() - expect(Member).toBeDefined() - }) -}) diff --git a/tests/server/setup.js b/tests/server/setup.js index ad47f25..f2fc815 100644 --- a/tests/server/setup.js +++ b/tests/server/setup.js @@ -15,9 +15,6 @@ import { sendRedirect } from 'h3' -// Real server/utils that are safe to use as-is in tests -import { escapeRegex } from '../../server/utils/escapeRegex.js' - // Register real h3 functions as globals so server code that relies on // Nitro auto-imports can find them in the test environment. vi.stubGlobal('getCookie', getCookie) @@ -45,5 +42,7 @@ vi.stubGlobal('requireAdmin', vi.fn()) vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event))) vi.stubGlobal('logActivity', vi.fn()) vi.stubGlobal('validateTagSlugs', vi.fn()) -vi.stubGlobal('autoFlagPreExistingSlackAccess', vi.fn().mockResolvedValue(undefined)) + +// Real server/utils that are safe to use as-is in tests +import { escapeRegex } from '../../server/utils/escapeRegex.js' vi.stubGlobal('escapeRegex', escapeRegex) diff --git a/tests/_archive/server/tasks/check-slack-joins.test.js b/tests/server/tasks/check-slack-joins.test.js similarity index 100% rename from tests/_archive/server/tasks/check-slack-joins.test.js rename to tests/server/tasks/check-slack-joins.test.js diff --git a/tests/server/utils/adminAlerts.test.js b/tests/server/utils/adminAlerts.test.js index 40769a9..aae36e2 100644 --- a/tests/server/utils/adminAlerts.test.js +++ b/tests/server/utils/adminAlerts.test.js @@ -1,23 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - ALERT_THRESHOLDS, - computeSignature, - detectNoSlackHandleAfterWeek, - detectStuckPendingPayment, - detectSuspendedMembers, - detectPreRegistrantSelectedNotInvited, - detectPreRegistrantExpired, - detectDraftEventsImminent, - detectEventsNearCapacity, detectPendingTagSuggestions , computeAllAlerts -} from '../../../server/utils/adminAlerts.js' -import Member from '../../../server/models/member.js' -import PreRegistration from '../../../server/models/preRegistration.js' -import Event from '../../../server/models/event.js' -import TagSuggestion from '../../../server/models/tagSuggestion.js' - -import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js' - vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) @@ -52,6 +34,7 @@ vi.mock('../../../server/models/adminAlertDismissal.js', () => ({ find: vi.fn() }, ADMIN_ALERT_TYPES: [ + 'slack_invite_failed', 'no_slack_handle_week', 'stuck_pending_payment', 'member_suspended', @@ -63,6 +46,24 @@ vi.mock('../../../server/models/adminAlertDismissal.js', () => ({ ] })) +import { + ALERT_THRESHOLDS, + computeSignature, + detectSlackInviteFailed, + detectNoSlackHandleAfterWeek, + detectStuckPendingPayment, + detectSuspendedMembers, + detectPreRegistrantSelectedNotInvited, + detectPreRegistrantExpired, + detectDraftEventsImminent, + detectEventsNearCapacity +} from '../../../server/utils/adminAlerts.js' +import Member from '../../../server/models/member.js' +import PreRegistration from '../../../server/models/preRegistration.js' +import Event from '../../../server/models/event.js' +import { detectPendingTagSuggestions } from '../../../server/utils/adminAlerts.js' +import TagSuggestion from '../../../server/models/tagSuggestion.js' + describe('adminAlerts module shell', () => { describe('ALERT_THRESHOLDS', () => { it('exposes the four documented thresholds', () => { @@ -112,6 +113,36 @@ describe('adminAlerts module shell', () => { }) } + describe('detectSlackInviteFailed', () => { + it('returns matching members with critical severity', async () => { + mockMemberFind([ + { _id: 'm1', name: 'Alex', email: 'alex@example.com' }, + { _id: 'm2', name: 'Bea', email: 'bea@example.com' } + ]) + + const alert = await detectSlackInviteFailed() + + expect(alert.type).toBe('slack_invite_failed') + expect(alert.severity).toBe('critical') + expect(alert.items).toHaveLength(2) + expect(alert.items[0]).toEqual({ + id: 'm1', + label: 'Alex', + sublabel: 'alex@example.com', + href: '/admin/members/m1' + }) + expect(Member.find).toHaveBeenCalledWith( + { slackInviteStatus: 'failed' } + ) + }) + + it('returns an empty list when nothing matches', async () => { + mockMemberFind([]) + const alert = await detectSlackInviteFailed() + expect(alert.items).toEqual([]) + }) + }) + describe('detectNoSlackHandleAfterWeek', () => { it('queries active members joined more than 7 days ago without a slackUserId', async () => { mockMemberFind([ @@ -361,6 +392,9 @@ describe('adminAlerts module shell', () => { }) }) +import { computeAllAlerts } from '../../../server/utils/adminAlerts.js' +import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js' + describe('computeAllAlerts aggregator', () => { beforeEach(() => { vi.clearAllMocks() @@ -389,7 +423,7 @@ describe('computeAllAlerts aggregator', () => { it('returns alerts that have items and have not been dismissed', async () => { Member.find.mockImplementation((query) => { - if (query.status === 'suspended') { + if (query.slackInviteStatus === 'failed') { return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' } ]) }) } @@ -399,13 +433,13 @@ describe('computeAllAlerts aggregator', () => { const alerts = await computeAllAlerts('admin-1') expect(alerts).toHaveLength(1) - expect(alerts[0].type).toBe('member_suspended') + expect(alerts[0].type).toBe('slack_invite_failed') expect(alerts[0].signature).toMatch(/^[a-f0-9]+$/) }) it('hides alerts whose signature matches an existing dismissal', async () => { Member.find.mockImplementation((query) => { - if (query.status === 'suspended') { + if (query.slackInviteStatus === 'failed') { return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' } ]) }) } @@ -416,7 +450,7 @@ describe('computeAllAlerts aggregator', () => { const sig = computeSignature(['m1']) AdminAlertDismissal.find.mockReturnValue({ lean: vi.fn().mockResolvedValue([ - { adminId: 'admin-1', alertType: 'member_suspended', signature: sig } + { adminId: 'admin-1', alertType: 'slack_invite_failed', signature: sig } ]) }) @@ -426,7 +460,7 @@ describe('computeAllAlerts aggregator', () => { it('shows an alert again when the underlying set changes', async () => { Member.find.mockImplementation((query) => { - if (query.status === 'suspended') { + if (query.slackInviteStatus === 'failed') { return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ { _id: 'm1', name: 'Alex', email: 'alex@example.com' }, { _id: 'm2', name: 'Bea', email: 'bea@example.com' } @@ -438,7 +472,7 @@ describe('computeAllAlerts aggregator', () => { const staleSig = computeSignature(['m1']) // dismissal was for the old single-member state AdminAlertDismissal.find.mockReturnValue({ lean: vi.fn().mockResolvedValue([ - { adminId: 'admin-1', alertType: 'member_suspended', signature: staleSig } + { adminId: 'admin-1', alertType: 'slack_invite_failed', signature: staleSig } ]) }) diff --git a/tests/server/utils/slack-cleanup.test.js b/tests/server/utils/slack-cleanup.test.js deleted file mode 100644 index b9c739d..0000000 --- a/tests/server/utils/slack-cleanup.test.js +++ /dev/null @@ -1,116 +0,0 @@ -// Spec: docs/specs/wave-based-slack-onboarding.md -// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §8 -// -// Static-source / filesystem assertions for the dead-code removal & -// archival checklist. These don't require booting any handler — they -// grep the working tree. -// -// SCAFFOLD: the whole suite is `.skip`ed until removal is done. As each -// chunk lands, unskip the matching `it`. - -import { describe, it, expect } from 'vitest' -import { readFileSync, existsSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' - -const repoRoot = resolve(import.meta.dirname, '../../..') - -function read(rel) { - const path = resolve(repoRoot, rel) - return existsSync(path) ? readFileSync(path, 'utf-8') : null -} - -function walk(dir, out = []) { - for (const entry of readdirSync(resolve(repoRoot, dir), { withFileTypes: true })) { - const rel = `${dir}/${entry.name}` - if (entry.isDirectory()) { - if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue - if (entry.name === '_archive') continue - walk(rel, out) - } else if (/\.(js|ts|vue|mjs)$/.test(entry.name)) { - out.push(rel) - } - } - return out -} - -describe('Slack onboarding — dead code removal (§8)', () => { - it('no admin.users.invite calls anywhere (8.1)', () => { - const files = walk('server').concat(walk('app')) - const hits = files.filter((f) => { - const src = read(f) - return src && /admin\.users\.invite/.test(src) - }) - expect(hits).toEqual([]) - }) - - it('inviteUserToSlack removed from slack.ts (8.2)', () => { - const src = read('server/utils/slack.ts') - expect(src).not.toMatch(/\binviteUserToSlack\b/) - }) - - it('findUserByEmail is callable (public or via wrapper) (8.3)', () => { - const src = read('server/utils/slack.ts') ?? '' - // Either the keyword `private` is removed from its declaration, - // OR a public wrapper exists. - const declLine = src.split('\n').find((l) => /findUserByEmail/.test(l)) ?? '' - const isPrivate = /\bprivate\b/.test(declLine) - const hasPublicWrapper = /export\s+(async\s+)?function\s+findUserByEmail/.test(src) - || /findUserByEmail\s*[:=]\s*async/.test(src) - expect(isPrivate && !hasPublicWrapper).toBe(false) - }) - - it('notifyNewMember retained (8.4)', () => { - const src = read('server/utils/slack.ts') ?? '' - expect(src).toMatch(/\bnotifyNewMember\b/) - }) - - it('checkSlackJoins archived, not at original path (8.5)', () => { - expect(existsSync(resolve(repoRoot, 'server/utils/checkSlackJoins.js'))).toBe(false) - // Any unloaded path is acceptable; check a couple of plausible ones. - const archived = - existsSync(resolve(repoRoot, 'server/utils/_archive/checkSlackJoins.js')) || - existsSync(resolve(repoRoot, 'server/_archive/utils/checkSlackJoins.js')) - expect(archived).toBe(true) - }) - - it('no active cron/Nitro registration triggers checkSlackJoins (8.6)', () => { - const candidates = [ - 'nitro.config.ts', - 'nitro.config.js', - 'nuxt.config.ts', - 'nuxt.config.js' - ].map(read).filter(Boolean) - const tasksDir = resolve(repoRoot, 'server/tasks') - const taskFiles = existsSync(tasksDir) ? walk('server/tasks') : [] - const allSrc = candidates.concat(taskFiles.map(read).filter(Boolean)).join('\n') - expect(allSrc).not.toMatch(/checkSlackJoins/) - }) - - it('adminAlerts no longer queries the legacy invite-status field (8.7)', () => { - const src = read('server/utils/adminAlerts.js') ?? '' - expect(src).not.toMatch(new RegExp('slackInvite' + 'Status')) - }) - - it('adminAlerts.test.js drops the removed branch (8.8)', () => { - const src = read('tests/server/utils/adminAlerts.test.js') ?? '' - expect(src).not.toMatch(new RegExp('slackInvite' + 'Status')) - expect(src).not.toMatch(/detectSlackInviteFailed/) - }) - - it('check-slack-joins.test.js archived alongside source (8.9)', () => { - expect(existsSync(resolve(repoRoot, 'tests/server/tasks/check-slack-joins.test.js'))).toBe(false) - }) - - it('zero references to the legacy invite-status field repo-wide (8.10)', () => { - const files = walk('server').concat(walk('app')).concat(walk('tests')) - const hits = files.filter((f) => { - const src = read(f) - return src && new RegExp('slackInvite' + 'Status').test(src) - }) - expect(hits).toEqual([]) - }) - - it.todo('migration / cleanup story for dev/staging documented (8.11)') - - it.todo('notifyNewMember invitationStatus arg uses canonical value at all call sites (8.12)') -}) diff --git a/tests/server/utils/slackAccess.test.js b/tests/server/utils/slackAccess.test.js deleted file mode 100644 index 03db079..0000000 --- a/tests/server/utils/slackAccess.test.js +++ /dev/null @@ -1,134 +0,0 @@ -// 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)') -})