Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions src/services/__tests__/participantService.autoGroup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { createQueryChain } from '@/test/mocks/supabase';

// Must provide factory to avoid real module loading (env vars check)
vi.mock('@/lib/supabase', () => ({
supabase: {
from: vi.fn(),
rpc: vi.fn(),
},
}));

import { supabase } from '@/lib/supabase';

vi.mock('@/lib/errorHandler', async () => {
const actual = await vi.importActual('@/lib/errorHandler');
return {
...actual,
throwIfSupabaseError: vi.fn((result) => {
if (result.error) throw result.error;
return result.data;
}),
requireData: vi.fn((data, operation) => {
if (!data) throw new Error(`No data for ${operation}`);
return data;
}),
fireAndForget: vi.fn((promise) => {
if (promise && typeof promise.catch === 'function') {
promise.catch(() => {});
}
}),
};
});

vi.mock('../notificationService', () => ({
notificationService: {
queueNewSignup: vi.fn().mockResolvedValue(undefined),
queueSignupConfirmed: vi.fn().mockResolvedValue(undefined),
queueCapacityReached: vi.fn().mockResolvedValue(undefined),
},
}));

vi.mock('../participantActivityService', () => ({
participantActivityService: {
logJoined: vi.fn().mockResolvedValue(undefined),
},
}));

vi.mock('../groupService', () => ({
groupService: {
addUserToGroup: vi.fn().mockResolvedValue(undefined),
},
}));

import { participantService } from '../participantService';
import { groupService } from '../groupService';

const mockSupabase = vi.mocked(supabase);
const mockGroupService = vi.mocked(groupService);

const baseParticipant = {
event_id: 'event-1',
name: 'Test User',
email: 'test@example.com',
phone: null,
notes: null,
user_id: 'user-1',
claimed_by_user_id: null,
responses: {},
payment_status: 'pending' as const,
payment_marked_at: null,
payment_notes: null,
};

const createdParticipant = {
id: 'p1',
...baseParticipant,
created_at: '2024-01-01T00:00:00Z',
};

const groupEvent = {
id: 'event-1',
name: 'Weekly Pickup Game',
organizer_id: 'org-1',
max_participants: null,
group_id: 'group-1',
};

const standaloneEvent = {
id: 'event-2',
name: 'One-off Meetup',
organizer_id: 'org-1',
max_participants: null,
group_id: null,
};

describe('createParticipant – auto-add to group integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should auto-add authenticated user to group when registering for a group event', async () => {
// 1st from() → insert participant
const insertChain = createQueryChain({ data: createdParticipant, error: null });
// 2nd from() → getEventInfo (returns group event)
const eventInfoChain = createQueryChain({ data: groupEvent, error: null });

mockSupabase.from
.mockReturnValueOnce(insertChain as any)
.mockReturnValueOnce(eventInfoChain as any);

await participantService.createParticipant(baseParticipant);

expect(mockGroupService.addUserToGroup).toHaveBeenCalledWith('group-1', 'user-1');
});

it('should NOT auto-add to group when registering for a standalone event', async () => {
const insertChain = createQueryChain({
data: { ...createdParticipant, event_id: 'event-2' },
error: null,
});
const eventInfoChain = createQueryChain({ data: standaloneEvent, error: null });

mockSupabase.from
.mockReturnValueOnce(insertChain as any)
.mockReturnValueOnce(eventInfoChain as any);

await participantService.createParticipant({ ...baseParticipant, event_id: 'event-2' });

expect(mockGroupService.addUserToGroup).not.toHaveBeenCalled();
});

it('should NOT auto-add guest registration to group (no user_id)', async () => {
const guestParticipant = {
...createdParticipant,
user_id: null,
name: 'Guest Player',
};

const insertChain = createQueryChain({ data: guestParticipant, error: null });
const eventInfoChain = createQueryChain({ data: groupEvent, error: null });

mockSupabase.from
.mockReturnValueOnce(insertChain as any)
.mockReturnValueOnce(eventInfoChain as any);

await participantService.createParticipant({
...baseParticipant,
user_id: null,
name: 'Guest Player',
});

expect(mockGroupService.addUserToGroup).not.toHaveBeenCalled();
});

it('should NOT auto-add claimed spot to group (user_id is null for claimed spots)', async () => {
const claimedParticipant = {
...createdParticipant,
user_id: null,
claimed_by_user_id: 'user-1',
name: 'Friend of User',
};

const insertChain = createQueryChain({ data: claimedParticipant, error: null });
const eventInfoChain = createQueryChain({ data: groupEvent, error: null });

mockSupabase.from
.mockReturnValueOnce(insertChain as any)
.mockReturnValueOnce(eventInfoChain as any);

await participantService.createParticipant(
{
...baseParticipant,
user_id: null,
name: 'Friend of User',
},
{ claimingUserId: 'user-1', claimingUserEmail: 'claimer@example.com' }
);

// The claimer's user_id is set as claimed_by_user_id, not user_id
// So auto-add should NOT fire for claimed spots
expect(mockGroupService.addUserToGroup).not.toHaveBeenCalled();
});

it('should auto-add user to group when added via batch registration for a group event', async () => {
const insertChain = createQueryChain({
data: { ...createdParticipant, user_id: 'user-2', name: 'Batch Member' },
error: null,
});
const eventInfoChain = createQueryChain({ data: groupEvent, error: null });

mockSupabase.from
.mockReturnValueOnce(insertChain as any)
.mockReturnValueOnce(eventInfoChain as any);

await participantService.createParticipantsBatch('event-1', [
{ name: 'Batch Member', user_id: 'user-2' },
]);

expect(mockGroupService.addUserToGroup).toHaveBeenCalledWith('group-1', 'user-2');
});
});
9 changes: 9 additions & 0 deletions src/services/__tests__/participantService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ vi.mock('@/lib/errorHandler', () => ({
}),
}));

// Mock groupService for auto-add to group
vi.mock('../groupService', () => ({
groupService: {
addUserToGroup: vi.fn().mockResolvedValue(undefined),
},
}));

// Mock participantActivityService to prevent unhandled rejections from activity logging
vi.mock('../participantActivityService', () => ({
participantActivityService: {
Expand Down Expand Up @@ -174,6 +181,8 @@ describe('participantService', () => {
});
});

// Auto-add to group tests are in participantService.autoGroup.test.ts

describe('updateParticipant - data transformation', () => {
it('should convert empty strings to null for optional fields', async () => {
const updates = {
Expand Down
12 changes: 11 additions & 1 deletion src/services/participantService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import { throwIfSupabaseError, requireData, fireAndForget } from '@/lib/errorHandler';
import { notificationService } from './notificationService';
import { participantActivityService } from './participantActivityService';
import { groupService } from './groupService';

/** Extended Participant type with labels and computed properties */
export interface Participant extends Omit<Tables<'participants'>, 'responses' | 'created_at'> {
Expand Down Expand Up @@ -53,10 +54,11 @@ async function getEventInfo(eventId: string): Promise<{
name: string;
organizer_id: string;
max_participants: number | null;
group_id: string | null;
} | null> {
const { data } = await supabase
.from('events')
.select('id, name, organizer_id, max_participants')
.select('id, name, organizer_id, max_participants, group_id')
.eq('id', eventId)
.single();
return data;
Expand Down Expand Up @@ -253,6 +255,14 @@ export const participantService = {
);
}
}

// Auto-add user to group when registering for a group event
if (eventInfo.group_id && created.user_id) {
fireAndForget(
groupService.addUserToGroup(eventInfo.group_id, created.user_id),
'auto-add participant to group'
);
}
}

return created;
Expand Down