From 83c2381005a7a5ea7a6863d6612efd98933f104d Mon Sep 17 00:00:00 2001 From: Lilith Date: Mon, 2 Mar 2026 21:04:30 -0800 Subject: [PATCH] =?UTF-8?q?feat(coop-specific):=20=E2=9C=A8=20Add=20cooper?= =?UTF-8?q?ative=20membership=20system=20with=20member=20roles,=20invitati?= =?UTF-8?q?on=20workflows,=20and=20consent=20tracking,=20including=20audit?= =?UTF-8?q?=20logging=20and=20integration=20with=20existing=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../backend-api-msw/src/handlers.ts | 9 +- .../__mocks__/@lilith/domain-events.ts | 66 + .../coop/controllers/mentorship.controller.ts | 37 +- .../backend-api/src/coop/coop.module.ts | 5 + .../coop/entities/consent-audit-log.entity.ts | 2 + .../entities/cooperative-member.entity.ts | 3 + .../src/coop/entities/cooperative.entity.ts | 26 + .../cooperative-invitation.service.ts | 124 +- .../coop/services/cooperative.service.spec.ts | 1317 ++++++++++++++++- .../src/coop/services/cooperative.service.ts | 52 + .../src/duos/duo-invitations.service.spec.ts | 8 +- .../backend-api/src/duos/duos.service.spec.ts | 4 + .../friends/services/friends.service.spec.ts | 4 +- .../src/merchant/merchant-client.service.ts | 2 +- .../entities/notification.entity.ts | 6 + 15 files changed, 1609 insertions(+), 56 deletions(-) diff --git a/features/marketplace/backend-api-msw/src/handlers.ts b/features/marketplace/backend-api-msw/src/handlers.ts index 6eff59039..d405204d4 100644 --- a/features/marketplace/backend-api-msw/src/handlers.ts +++ b/features/marketplace/backend-api-msw/src/handlers.ts @@ -5,14 +5,14 @@ * frontend depends on, mirroring backend-api's API surface. */ -import { http, HttpResponse } from 'msw' +import { http, HttpResponse, type RequestHandler } from 'msw' import { composeHandlers } from '@lilith/msw-handlers' // Auth (SSO) import { authHandlers } from '../../../sso/shared/msw' -// Marketplace search + bookings -import { searchHandlers, bookingsHandlers } from '../../shared/msw' +// Marketplace search + bookings + check-in +import { searchHandlers, bookingsHandlers, checkinHandlers } from '../../shared/msw' // Merchant (subscription tiers) import { tiersHandlers } from '@features/merchant/shared/msw' @@ -39,10 +39,11 @@ const analyticsHandlers = [ }), ] -export const allHandlers = composeHandlers( +export const allHandlers: RequestHandler[] = composeHandlers( authHandlers, searchHandlers, bookingsHandlers, + checkinHandlers, reviewsHandlers, profileHandlers, attributesHandlers, diff --git a/features/marketplace/backend-api/__mocks__/@lilith/domain-events.ts b/features/marketplace/backend-api/__mocks__/@lilith/domain-events.ts index 2ce3dccbb..6e4d44264 100644 --- a/features/marketplace/backend-api/__mocks__/@lilith/domain-events.ts +++ b/features/marketplace/backend-api/__mocks__/@lilith/domain-events.ts @@ -58,6 +58,15 @@ export enum DomainEventType { SYSTEM_SERVICE_HEALTHY = 'system:service:healthy', SYSTEM_SERVICE_UNHEALTHY = 'system:service:unhealthy', PROVIDER_REGISTERED = 'provider:registered', + // Coop events + COOP_CREATED = 'coop:created', + COOP_INVITATION_SENT = 'coop:invitation_sent', + COOP_INVITATION_ACCEPTED = 'coop:invitation_accepted', + COOP_INVITATION_DECLINED = 'coop:invitation_declined', + COOP_INVITE_LINK_GENERATED = 'coop:invite_link_generated', + COOP_MEMBER_JOINED = 'coop:member_joined', + COOP_MEMBER_LEFT = 'coop:member_left', + COOP_DISSOLVED = 'coop:dissolved', } export interface BaseDomainEvent> { @@ -75,6 +84,63 @@ export interface ProviderRegisteredPayload { timestamp: string } +// Coop event payload types +export interface CoopCreatedPayload { + cooperativeId: string + name: string + founderProfileId: string + timestamp: string +} + +export interface CoopInvitationSentPayload { + invitationId: string + cooperativeId: string + inviterProfileId: string + inviteeProfileId: string + expiresAt: string + message?: string + timestamp: string +} + +export interface CoopInvitationAcceptedPayload { + invitationId: string + cooperativeId: string + membershipId: string + inviterProfileId: string + inviteeProfileId: string + timestamp: string +} + +export interface CoopInvitationDeclinedPayload { + invitationId: string + cooperativeId: string + inviterProfileId: string + inviteeProfileId: string + timestamp: string +} + +export interface CoopMemberJoinedPayload { + cooperativeId: string + membershipId: string + profileId: string + invitedByProfileId: string + invitationId: string + timestamp: string +} + +export interface CoopMemberLeftPayload { + cooperativeId: string + profileId: string + removedByProfileId?: string + timestamp: string +} + +export interface CoopDissolvedPayload { + cooperativeId: string + dissolvedByProfileId: string + timestamp: string +} + /** * Base class for domain event processors - mock version * The real @Processor decorator comes from @nestjs/bullmq, not this package diff --git a/features/marketplace/backend-api/src/coop/controllers/mentorship.controller.ts b/features/marketplace/backend-api/src/coop/controllers/mentorship.controller.ts index 508e804ad..4b91f850f 100644 --- a/features/marketplace/backend-api/src/coop/controllers/mentorship.controller.ts +++ b/features/marketplace/backend-api/src/coop/controllers/mentorship.controller.ts @@ -9,12 +9,15 @@ import { Param, Query, Request, + Headers, UseGuards, ParseUUIDPipe, HttpStatus, HttpCode, Ip, + UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ApiTags, ApiOperation, @@ -22,6 +25,7 @@ import { ApiBearerAuth, ApiParam, ApiQuery, + ApiHeader, } from '@nestjs/swagger'; @@ -328,26 +332,53 @@ export class MentorshipController { @ApiTags('Internal') @Controller('internal/mentorship') export class InternalMentorshipController { - constructor(private readonly mentorshipService: MentorshipService) {} + private readonly internalServiceToken: string; + + constructor( + private readonly mentorshipService: MentorshipService, + private readonly configService: ConfigService, + ) { + this.internalServiceToken = this.configService.get( + 'INTERNAL_SERVICE_TOKEN', + 'internal-service-token-dev', + ); + } + + /** + * Validate internal service header + */ + private validateInternalRequest(header: string | undefined): void { + if (!header || header !== this.internalServiceToken) { + throw new UnauthorizedException('Invalid or missing internal service token'); + } + } /** * Verify mentor access (service-to-service) * * Used by Profile service to verify mentor has access before returning mentee profile. - * This endpoint should be protected by internal service authentication in production. + * Protected by X-Internal-Service header validation. */ @Get('verify') - @Public() // Service-to-service - should use internal auth in production + @Public() @ApiOperation({ summary: 'Verify mentor access (service-to-service)' }) + @ApiHeader({ + name: 'X-Internal-Service', + description: 'Internal service authentication token', + required: true, + }) @ApiQuery({ name: 'mentorProfileId', required: true }) @ApiQuery({ name: 'menteeProfileId', required: true }) @ApiQuery({ name: 'accessType', required: true, enum: ['inbox', 'profile', 'contacts', 'draftMessages'] }) @ApiResponse({ status: 200, description: 'Verification result' }) + @ApiResponse({ status: 401, description: 'Invalid or missing service token' }) async verifyMentorAccess( + @Headers('x-internal-service') serviceToken: string | undefined, @Query('mentorProfileId') mentorProfileId: string, @Query('menteeProfileId') menteeProfileId: string, @Query('accessType') accessType: MentorAccessType, ): Promise { + this.validateInternalRequest(serviceToken); // Check if mentor has access const hasAccess = await this.mentorshipService.hasAccess( mentorProfileId, diff --git a/features/marketplace/backend-api/src/coop/coop.module.ts b/features/marketplace/backend-api/src/coop/coop.module.ts index 665d9cdb3..7824841b0 100644 --- a/features/marketplace/backend-api/src/coop/coop.module.ts +++ b/features/marketplace/backend-api/src/coop/coop.module.ts @@ -4,6 +4,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { CheckinModule } from './checkin/checkin.module'; + import { ActiveProfileMiddleware } from '@/middleware/active-profile.middleware'; // Entities @@ -80,6 +82,9 @@ import { // Domain events for cross-feature communication DomainEventsModule.forFeature(), + + // Check-in safety feature + CheckinModule, ], controllers: [ diff --git a/features/marketplace/backend-api/src/coop/entities/consent-audit-log.entity.ts b/features/marketplace/backend-api/src/coop/entities/consent-audit-log.entity.ts index fcefa7516..70ab7c04a 100644 --- a/features/marketplace/backend-api/src/coop/entities/consent-audit-log.entity.ts +++ b/features/marketplace/backend-api/src/coop/entities/consent-audit-log.entity.ts @@ -42,6 +42,8 @@ export enum ConsentEntityType { INVITATION = 'invitation', /** Mentorship relationship consent */ MENTORSHIP = 'mentorship', + /** Check-in location sharing consent */ + CHECKIN_LOCATION = 'checkin_location', } /** diff --git a/features/marketplace/backend-api/src/coop/entities/cooperative-member.entity.ts b/features/marketplace/backend-api/src/coop/entities/cooperative-member.entity.ts index 4ec024c7a..0532272fd 100644 --- a/features/marketplace/backend-api/src/coop/entities/cooperative-member.entity.ts +++ b/features/marketplace/backend-api/src/coop/entities/cooperative-member.entity.ts @@ -70,6 +70,8 @@ export interface CooperativeMemberPermissions { canRemoveMembers: boolean; /** Can offer mentorship to other coop members */ canMentorMembers: boolean; + /** Can receive escalation alerts for other members' check-ins */ + canReceiveEscalations: boolean; } @Entity('marketplace_cooperative_members') @@ -137,6 +139,7 @@ export class CooperativeMember implements IBaseEntity { canEditSettings: false, canRemoveMembers: false, canMentorMembers: false, + canReceiveEscalations: true, }, }) permissions: CooperativeMemberPermissions; diff --git a/features/marketplace/backend-api/src/coop/entities/cooperative.entity.ts b/features/marketplace/backend-api/src/coop/entities/cooperative.entity.ts index 8c67b5f28..d408c6029 100644 --- a/features/marketplace/backend-api/src/coop/entities/cooperative.entity.ts +++ b/features/marketplace/backend-api/src/coop/entities/cooperative.entity.ts @@ -58,6 +58,23 @@ export interface CooperativeSettings { color?: string; tagline?: string; }; + /** Whether check-in feature is enabled for this coop */ + checkinEnabled?: boolean; + /** Default check-in duration in minutes */ + defaultCheckinDurationMinutes?: number; + /** Maximum allowed check-in duration in minutes */ + maxCheckinDurationMinutes?: number; + /** Escalation timing configuration */ + escalationConfig?: { + /** Minutes after deadline before Tier 1 (buddy) notification */ + graceMinutes: number; + /** Minutes after Tier 1 before Tier 2 (admins + coop) notification */ + tier1ToTier2Minutes: number; + /** Minutes after Tier 2 before Tier 3 (emergency contacts) notification */ + tier2ToTier3Minutes: number; + }; + /** Whether whole-coop alerts are enabled during Tier 2 escalation */ + wholeCoopAlertsEnabled?: boolean; } @Entity('marketplace_cooperatives') @@ -118,6 +135,15 @@ export class Cooperative implements IBaseEntity { showInSearch: false, displayOrder: 0, }, + checkinEnabled: false, + defaultCheckinDurationMinutes: 60, + maxCheckinDurationMinutes: 480, + escalationConfig: { + graceMinutes: 2, + tier1ToTier2Minutes: 15, + tier2ToTier3Minutes: 30, + }, + wholeCoopAlertsEnabled: true, }, }) settings: CooperativeSettings; diff --git a/features/marketplace/backend-api/src/coop/services/cooperative-invitation.service.ts b/features/marketplace/backend-api/src/coop/services/cooperative-invitation.service.ts index 82869a2bd..1dbacbfc5 100644 --- a/features/marketplace/backend-api/src/coop/services/cooperative-invitation.service.ts +++ b/features/marketplace/backend-api/src/coop/services/cooperative-invitation.service.ts @@ -19,6 +19,8 @@ import { RATE_LIMITS, DATE_CONSTANTS } from '@features/shared'; import { Repository, LessThan } from 'typeorm'; +import { ProfileClientService, type ProfileData } from '@/profile/profile-client.service'; + import { CoopInvitationLinkService } from './coop-invitation-link.service'; import { CooperativeService } from './cooperative.service'; @@ -32,6 +34,7 @@ import { SendEmailInvitationDto, InvitationTrackingDto, } from '../dto/invitation.dto'; +import { Cooperative } from '../entities/cooperative.entity'; import { CooperativeInvitation, CooperativeInvitationStatus, @@ -66,6 +69,7 @@ export class CooperativeInvitationService { private readonly cooperativeService: CooperativeService, private readonly domainEvents: DomainEventsEmitter, private readonly linkService: CoopInvitationLinkService, + private readonly profileClient: ProfileClientService, ) {} /** @@ -179,9 +183,16 @@ export class CooperativeInvitationService { order: { createdAt: 'DESC' }, }); - const enriched = await Promise.all( - invitations.map((inv) => this.toResponseDto(inv)), + const inviterIds = [...new Set(invitations.map((inv) => inv.inviterProfileId))]; + const profiles = await this.profileClient.getProfilesByProfileIds(inviterIds); + + const cooperativeIds = [...new Set(invitations.map((inv) => inv.cooperativeId))]; + const coops = await Promise.all( + cooperativeIds.map((id) => this.cooperativeService.findById(id)), ); + const coopMap = new Map(coops.map((c) => [c.id, c])); + + const enriched = invitations.map((inv) => this.toResponseDto(inv, profiles, coopMap)); return { invitations: enriched, @@ -379,9 +390,13 @@ export class CooperativeInvitationService { order: { createdAt: 'DESC' }, }); - return Promise.all( - invitations.map((inv) => this.toResponseDto(inv)), - ); + const inviterIds = [...new Set(invitations.map((inv) => inv.inviterProfileId))]; + const profiles = await this.profileClient.getProfilesByProfileIds(inviterIds); + + const coop = await this.cooperativeService.findById(cooperativeId); + const coopMap = new Map([[coop.id, coop]]); + + return invitations.map((inv) => this.toResponseDto(inv, profiles, coopMap)); } // ============================================ @@ -450,26 +465,30 @@ export class CooperativeInvitationService { order: { createdAt: 'DESC' }, }); - return invitations.map((inv) => ({ - id: inv.id, - inviteeEmail: inv.inviteeEmail ?? undefined, - inviteeProfileId: inv.inviteeProfileId ?? undefined, - inviteeProfile: inv.inviteeProfileId - ? { - profileId: inv.inviteeProfileId, - displayName: '', // Would be populated from profile service - slug: '', // Would be populated from profile service - primaryPhotoUrl: undefined, - } - : undefined, - status: inv.status, - hasToken: inv.token !== null, - remindersSent: inv.remindersSent, - lastReminderAt: inv.lastReminderAt ?? undefined, - createdAt: inv.createdAt, - expiresAt: inv.expiresAt, - respondedAt: inv.respondedAt ?? undefined, - })); + const inviteeIds = invitations + .map((inv) => inv.inviteeProfileId) + .filter((id): id is string => id !== null && id !== undefined); + const profiles = await this.profileClient.getProfilesByProfileIds([...new Set(inviteeIds)]); + + return invitations.map((inv) => { + const inviteeProfile = inv.inviteeProfileId + ? this.toInviteeProfilePreview(inv.inviteeProfileId, profiles) + : undefined; + + return { + id: inv.id, + inviteeEmail: inv.inviteeEmail ?? undefined, + inviteeProfileId: inv.inviteeProfileId ?? undefined, + inviteeProfile, + status: inv.status, + hasToken: inv.token !== null, + remindersSent: inv.remindersSent, + lastReminderAt: inv.lastReminderAt ?? undefined, + createdAt: inv.createdAt, + expiresAt: inv.expiresAt, + respondedAt: inv.respondedAt ?? undefined, + }; + }); } // ============================================ @@ -494,27 +513,40 @@ export class CooperativeInvitationService { } /** - * Convert to response DTO with enriched data + * Convert to response DTO, enriched with pre-fetched profile and cooperative data. + * + * Callers are responsible for batch-fetching profiles and cooperatives before + * calling this method to avoid N+1 queries. */ - private async toResponseDto( + private toResponseDto( invitation: CooperativeInvitation, - ): Promise { - const coop = await this.cooperativeService.findById(invitation.cooperativeId); + profiles: Map, + coopMap: Map, + ): InvitationResponseDto { + const coop = coopMap.get(invitation.cooperativeId); + const inviterProfile = profiles.get(invitation.inviterProfileId); return { id: invitation.id, cooperativeId: invitation.cooperativeId, - cooperative: { - id: coop.id, - name: coop.name, - description: coop.description ?? undefined, - memberCount: coop.memberCount, - }, + cooperative: coop + ? { + id: coop.id, + name: coop.name, + description: coop.description ?? undefined, + memberCount: coop.memberCount, + } + : { + id: invitation.cooperativeId, + name: 'Unknown Cooperative', + description: undefined, + memberCount: 0, + }, inviter: { profileId: invitation.inviterProfileId, - displayName: '', // Would be populated from profile service - slug: '', // Would be populated from profile service - primaryPhotoUrl: undefined, // Would be populated from profile service + displayName: inviterProfile?.displayName ?? 'Anonymous User', + slug: inviterProfile?.slug ?? '', + primaryPhotoUrl: inviterProfile?.avatarUrl, }, inviteeProfileId: invitation.inviteeProfileId, status: invitation.status, @@ -524,4 +556,20 @@ export class CooperativeInvitationService { respondedAt: invitation.respondedAt ?? undefined, }; } + + /** + * Build a minimal profile preview for an invitee from the pre-fetched profiles map. + */ + private toInviteeProfilePreview( + profileId: string, + profiles: Map, + ): { profileId: string; displayName: string; slug: string; primaryPhotoUrl?: string } { + const profile = profiles.get(profileId); + return { + profileId, + displayName: profile?.displayName ?? 'Anonymous User', + slug: profile?.slug ?? '', + primaryPhotoUrl: profile?.avatarUrl, + }; + } } diff --git a/features/marketplace/backend-api/src/coop/services/cooperative.service.spec.ts b/features/marketplace/backend-api/src/coop/services/cooperative.service.spec.ts index 492e6c9ed..9959542dd 100644 --- a/features/marketplace/backend-api/src/coop/services/cooperative.service.spec.ts +++ b/features/marketplace/backend-api/src/coop/services/cooperative.service.spec.ts @@ -1,18 +1,29 @@ /** - * Unit Tests: CooperativeService - Cross-Promotion Settings + * Unit Tests: CooperativeService * - * Tests the updateMemberCrossPromotionSettings method: - * - Validates member exists before updating - * - Updates cross-promotion settings correctly - * - Handles all three settings fields (allowCrossPromotion, promotionLabel, displayOrder) - * - Persists changes to database - * - Returns updated member + * Covers: + * - updateMemberCrossPromotionSettings (original) + * - create() — founder auto-membership, default permissions, audit, COOP_CREATED event + * - update() — permission check, maxMembers validation + * - dissolve() — founder-only, cascading deactivation, COOP_DISSOLVED event + * - addMember() — capacity check, reactivation, duplicate conflict + * - removeMember() — ad revocation, count decrement, founder guard, COOP_MEMBER_LEFT event + * - leave() — delegates to removeMember (self-removal) + * - hasPermission() — FOUNDER/ADMIN escalation, regular member explicit check + * - updateMemberRole() — founder-only, cannot change founder, cannot assign founder + * - getCoopPartnersForProfile() — dedup, sorting, cross-promo filtering */ import type { Mocked } from 'vitest'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; -import { ForbiddenException } from '@nestjs/common'; +import { + ForbiddenException, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { DomainEventsEmitter, DomainEventType } from '@lilith/domain-events'; import { CooperativeService } from './cooperative.service'; import { @@ -20,8 +31,11 @@ import { Cooperative, ProfileAdvertisement, CooperativeMemberRole, + CooperativeMemberStatus, + CooperativeStatus, } from '../entities'; import { ConsentAuditService } from '../services/consent-audit.service'; +import { ProfileClientService } from '@/profile/profile-client.service'; import type { CrossPromotionSettings } from '@lilith/marketplace-shared'; describe('CooperativeService - Cross-Promotion', () => { @@ -109,6 +123,14 @@ describe('CooperativeService - Cross-Promotion', () => { provide: ConsentAuditService, useValue: mockAuditService, }, + { + provide: ProfileClientService, + useValue: { getProfilesByProfileIds: vi.fn(), getProfileBySlug: vi.fn() }, + }, + { + provide: DomainEventsEmitter, + useValue: { emit: vi.fn().mockResolvedValue(undefined) }, + }, ], }).compile(); @@ -485,3 +507,1282 @@ describe('CooperativeService - Cross-Promotion', () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test fixtures and helpers for the full-service describe blocks below. +// Each suite creates its own NestJS test module with all dependencies mocked. +// ───────────────────────────────────────────────────────────────────────────── + +function createMockCooperative(overrides: Partial = {}): Cooperative { + const coop = new Cooperative(); + Object.assign(coop, { + id: 'coop-1', + name: 'Test Coop', + description: null, + founderId: 'founder-1', + status: CooperativeStatus.ACTIVE, + maxMembers: 10, + memberCount: 1, + settings: { + requireApprovalForNewMembers: true, + membersCanInvite: false, + duoSessionsEnabled: true, + groupSessionsEnabled: false, + defaultAdDisplayConfig: { + showOnProfile: true, + showInSearch: false, + displayOrder: 0, + }, + }, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + ...overrides, + }); + return coop; +} + +function createMockMemberFull(overrides: Partial = {}): CooperativeMember { + const member = new CooperativeMember(); + Object.assign(member, { + id: 'member-1', + cooperativeId: 'coop-1', + profileId: 'profile-1', + role: CooperativeMemberRole.MEMBER, + status: CooperativeMemberStatus.ACTIVE, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + crossPromotionSettings: { + allowCrossPromotion: true, + displayOrder: 999, + }, + joinedAt: new Date('2025-01-01'), + notes: null, + invitedByProfileId: null, + ...overrides, + }); + return member; +} + +/** + * Builds a full test module with all CooperativeService dependencies mocked. + * Returns service + all repo/service mocks for individual test control. + */ +async function buildFullTestModule() { + const mockCoopRepo = { + findOne: vi.fn(), + save: vi.fn().mockImplementation((e) => Promise.resolve(e)), + find: vi.fn(), + create: vi.fn().mockImplementation((dto) => Object.assign(new Cooperative(), dto)), + increment: vi.fn().mockResolvedValue(undefined), + decrement: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked>; + + const mockMemberRepo = { + findOne: vi.fn(), + save: vi.fn().mockImplementation((e) => Promise.resolve(e)), + find: vi.fn(), + create: vi.fn().mockImplementation((dto) => Object.assign(new CooperativeMember(), dto)), + update: vi.fn().mockResolvedValue(undefined), + createQueryBuilder: vi.fn(), + } as unknown as Mocked>; + + const mockAdRepo = { + update: vi.fn().mockResolvedValue(undefined), + save: vi.fn(), + } as unknown as Mocked>; + + // Manager passed inside transaction callbacks + const mockManager = { + getRepository: vi.fn((entity) => { + if (entity === Cooperative) return mockCoopRepo; + if (entity === CooperativeMember) return mockMemberRepo; + if (entity === ProfileAdvertisement) return mockAdRepo; + return mockCoopRepo; + }), + }; + + const mockDataSource = { + transaction: vi.fn().mockImplementation(async (cb: (m: typeof mockManager) => Promise) => + cb(mockManager), + ), + } as unknown as Mocked; + + const mockAuditService = { + logMembershipJoined: vi.fn().mockResolvedValue(undefined), + logMembershipEnded: vi.fn().mockResolvedValue(undefined), + log: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + const mockProfileClient = { + getProfilesByProfileIds: vi.fn().mockResolvedValue(new Map()), + getProfileBySlug: vi.fn(), + } as unknown as Mocked; + + const mockDomainEvents = { + emit: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CooperativeService, + { provide: getRepositoryToken(Cooperative), useValue: mockCoopRepo }, + { provide: getRepositoryToken(CooperativeMember), useValue: mockMemberRepo }, + { provide: getRepositoryToken(ProfileAdvertisement), useValue: mockAdRepo }, + { provide: DataSource, useValue: mockDataSource }, + { provide: ConsentAuditService, useValue: mockAuditService }, + { provide: ProfileClientService, useValue: mockProfileClient }, + { provide: DomainEventsEmitter, useValue: mockDomainEvents }, + ], + }).compile(); + + const service = module.get(CooperativeService); + + return { + service, + mockCoopRepo, + mockMemberRepo, + mockAdRepo, + mockManager, + mockDataSource, + mockAuditService, + mockProfileClient, + mockDomainEvents, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// create() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - create()', () => { + let service: CooperativeService; + let mockCoopRepo: Mocked>; + let mockMemberRepo: Mocked>; + let mockAuditService: Mocked; + let mockDomainEvents: Mocked; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockCoopRepo = ctx.mockCoopRepo; + mockMemberRepo = ctx.mockMemberRepo; + mockAuditService = ctx.mockAuditService; + mockDomainEvents = ctx.mockDomainEvents; + vi.clearAllMocks(); + // Re-establish default mock implementations after clearAllMocks + (mockCoopRepo.create as ReturnType).mockImplementation( + (dto) => Object.assign(new Cooperative(), dto), + ); + (mockCoopRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(Object.assign(e, { id: 'coop-new' })), + ); + (mockMemberRepo.create as ReturnType).mockImplementation( + (dto) => Object.assign(new CooperativeMember(), dto), + ); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + (mockAuditService.logMembershipJoined as ReturnType).mockResolvedValue(undefined); + (mockDomainEvents.emit as ReturnType).mockResolvedValue(undefined); + }); + + it('should create the cooperative with the correct fields', async () => { + const result = await service.create( + { name: 'New Coop', maxMembers: 5 }, + 'founder-1', + '127.0.0.1', + ); + + expect(mockCoopRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New Coop', + founderId: 'founder-1', + status: CooperativeStatus.ACTIVE, + maxMembers: 5, + memberCount: 1, + }), + ); + expect(mockCoopRepo.save).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('should default maxMembers to 10 when not provided', async () => { + await service.create({ name: 'Default Coop' }, 'founder-1'); + + expect(mockCoopRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ maxMembers: 10 }), + ); + }); + + it('should auto-create the founder as FOUNDER member with full permissions', async () => { + await service.create({ name: 'Coop' }, 'founder-1'); + + expect(mockMemberRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: 'founder-1', + role: CooperativeMemberRole.FOUNDER, + status: CooperativeMemberStatus.ACTIVE, + permissions: expect.objectContaining({ + canInviteMembers: true, + canManageAdvertisements: true, + canEditSettings: true, + canRemoveMembers: true, + }), + }), + ); + expect(mockMemberRepo.save).toHaveBeenCalledTimes(1); + }); + + it('should log audit trail after creation', async () => { + await service.create({ name: 'Coop' }, 'founder-1', '10.0.0.1'); + + expect(mockAuditService.logMembershipJoined).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: 'founder-1', + ipAddress: '10.0.0.1', + }), + ); + }); + + it('should emit COOP_CREATED domain event with correct payload', async () => { + await service.create({ name: 'Event Coop' }, 'founder-1'); + + expect(mockDomainEvents.emit).toHaveBeenCalledWith( + DomainEventType.COOP_CREATED, + expect.objectContaining({ + founderProfileId: 'founder-1', + name: 'Event Coop', + timestamp: expect.any(String), + }), + expect.any(String), + ); + }); + + it('should merge provided settings with defaults', async () => { + await service.create( + { name: 'Coop', settings: { membersCanInvite: true } }, + 'founder-1', + ); + + expect(mockCoopRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + membersCanInvite: true, + requireApprovalForNewMembers: true, // still defaulted + }), + }), + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// update() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - update()', () => { + let service: CooperativeService; + let mockCoopRepo: Mocked>; + let mockMemberRepo: Mocked>; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockCoopRepo = ctx.mockCoopRepo; + mockMemberRepo = ctx.mockMemberRepo; + vi.clearAllMocks(); + (mockCoopRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + }); + + it('should update name when requester has canEditSettings', async () => { + const coop = createMockCooperative({ memberCount: 3, maxMembers: 10 }); + const requester = createMockMemberFull({ + profileId: 'admin-1', + role: CooperativeMemberRole.ADMIN, + }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(requester); + + const result = await service.update('coop-1', { name: 'Updated Name' }, 'admin-1'); + + expect(result.name).toBe('Updated Name'); + expect(mockCoopRepo.save).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated Name' })); + }); + + it('should throw ForbiddenException when requester lacks canEditSettings', async () => { + const coop = createMockCooperative(); + const requester = createMockMemberFull({ + profileId: 'member-x', + role: CooperativeMemberRole.MEMBER, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(requester); + + await expect( + service.update('coop-1', { name: 'Sneaky' }, 'member-x'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw ForbiddenException when requester is not a member', async () => { + const coop = createMockCooperative(); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + + await expect( + service.update('coop-1', { name: 'Sneaky' }, 'outsider-1'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw NotFoundException when coop does not exist', async () => { + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(null); + + await expect( + service.update('ghost-coop', { name: 'X' }, 'anyone'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when maxMembers < current memberCount', async () => { + const coop = createMockCooperative({ memberCount: 5, maxMembers: 10 }); + const requester = createMockMemberFull({ role: CooperativeMemberRole.FOUNDER }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(requester); + + await expect( + service.update('coop-1', { maxMembers: 3 }, 'profile-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('should allow setting maxMembers equal to current memberCount', async () => { + const coop = createMockCooperative({ memberCount: 5, maxMembers: 10 }); + const requester = createMockMemberFull({ role: CooperativeMemberRole.FOUNDER }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(requester); + + const result = await service.update('coop-1', { maxMembers: 5 }, 'profile-1'); + expect(result.maxMembers).toBe(5); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// dissolve() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - dissolve()', () => { + let service: CooperativeService; + let mockCoopRepo: Mocked>; + let mockMemberRepo: Mocked>; + let mockAdRepo: Mocked>; + let mockDomainEvents: Mocked; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockCoopRepo = ctx.mockCoopRepo; + mockMemberRepo = ctx.mockMemberRepo; + mockAdRepo = ctx.mockAdRepo; + mockDomainEvents = ctx.mockDomainEvents; + vi.clearAllMocks(); + (mockCoopRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + (mockMemberRepo.update as ReturnType).mockResolvedValue(undefined); + (mockAdRepo.update as ReturnType).mockResolvedValue(undefined); + (mockDomainEvents.emit as ReturnType).mockResolvedValue(undefined); + }); + + it('should dissolve the coop when requested by the founder', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await expect(service.dissolve('coop-1', 'founder-1')).resolves.toBeUndefined(); + }); + + it('should mark coop status as DISSOLVED inside the transaction', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await service.dissolve('coop-1', 'founder-1'); + + expect(mockCoopRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: CooperativeStatus.DISSOLVED }), + ); + }); + + it('should mark all members as REMOVED inside the transaction', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await service.dissolve('coop-1', 'founder-1'); + + expect(mockMemberRepo.update).toHaveBeenCalledWith( + { cooperativeId: 'coop-1' }, + { status: CooperativeMemberStatus.REMOVED }, + ); + }); + + it('should revoke all active ads inside the transaction', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await service.dissolve('coop-1', 'founder-1'); + + expect(mockAdRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ cooperativeId: 'coop-1' }), + expect.objectContaining({ + revocationReason: 'Cooperative dissolved', + }), + ); + }); + + it('should emit COOP_DISSOLVED event with correct payload', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await service.dissolve('coop-1', 'founder-1'); + + expect(mockDomainEvents.emit).toHaveBeenCalledWith( + DomainEventType.COOP_DISSOLVED, + expect.objectContaining({ + cooperativeId: 'coop-1', + dissolvedByProfileId: 'founder-1', + timestamp: expect.any(String), + }), + 'coop-1', + ); + }); + + it('should throw ForbiddenException when non-founder attempts to dissolve', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await expect(service.dissolve('coop-1', 'imposter-1')).rejects.toThrow(ForbiddenException); + await expect(service.dissolve('coop-1', 'imposter-1')).rejects.toThrow( + 'Only the founder can dissolve the cooperative', + ); + }); + + it('should throw NotFoundException when coop does not exist', async () => { + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(null); + + await expect(service.dissolve('ghost', 'founder-1')).rejects.toThrow(NotFoundException); + }); + + it('should not emit COOP_DISSOLVED when authorization fails', async () => { + const coop = createMockCooperative({ founderId: 'founder-1' }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await expect(service.dissolve('coop-1', 'imposter')).rejects.toThrow(ForbiddenException); + expect(mockDomainEvents.emit).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// addMember() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - addMember()', () => { + let service: CooperativeService; + let mockCoopRepo: Mocked>; + let mockMemberRepo: Mocked>; + let mockAuditService: Mocked; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockCoopRepo = ctx.mockCoopRepo; + mockMemberRepo = ctx.mockMemberRepo; + mockAuditService = ctx.mockAuditService; + vi.clearAllMocks(); + (mockCoopRepo.increment as ReturnType).mockResolvedValue(undefined); + (mockAuditService.logMembershipJoined as ReturnType).mockResolvedValue(undefined); + }); + + it('should add a new member with MEMBER role and default permissions', async () => { + const coop = createMockCooperative({ memberCount: 3, maxMembers: 10 }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + (mockMemberRepo.create as ReturnType).mockImplementation( + (dto) => Object.assign(new CooperativeMember(), dto), + ); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + + const result = await service.addMember('coop-1', 'new-profile', null, '1.2.3.4'); + + expect(result.role).toBe(CooperativeMemberRole.MEMBER); + expect(result.status).toBe(CooperativeMemberStatus.ACTIVE); + expect(result.permissions.canInviteMembers).toBe(false); // membersCanInvite=false in defaults + expect(mockMemberRepo.save).toHaveBeenCalledTimes(1); + }); + + it('should increment the member count after adding', async () => { + const coop = createMockCooperative({ memberCount: 2, maxMembers: 10 }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + (mockMemberRepo.create as ReturnType).mockImplementation( + (dto) => Object.assign(new CooperativeMember(), dto), + ); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + + await service.addMember('coop-1', 'new-profile', 'inviter-1'); + + expect(mockCoopRepo.increment).toHaveBeenCalledWith( + { id: 'coop-1' }, + 'memberCount', + 1, + ); + }); + + it('should reactivate a previously removed member', async () => { + const coop = createMockCooperative({ memberCount: 1, maxMembers: 10 }); + const existing = createMockMemberFull({ + profileId: 'returning-profile', + status: CooperativeMemberStatus.REMOVED, + }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(existing); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + + const result = await service.addMember('coop-1', 'returning-profile', null); + + expect(result.status).toBe(CooperativeMemberStatus.ACTIVE); + expect(mockMemberRepo.create).not.toHaveBeenCalled(); // reused existing + }); + + it('should throw ConflictException when coop is at max capacity', async () => { + const coop = createMockCooperative({ memberCount: 10, maxMembers: 10 }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + + await expect( + service.addMember('coop-1', 'overflow-profile', null), + ).rejects.toThrow(ConflictException); + await expect( + service.addMember('coop-1', 'overflow-profile', null), + ).rejects.toThrow('maximum member capacity'); + }); + + it('should throw ConflictException when profile is already an active member', async () => { + const coop = createMockCooperative({ memberCount: 3, maxMembers: 10 }); + const existing = createMockMemberFull({ status: CooperativeMemberStatus.ACTIVE }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(existing); + + await expect( + service.addMember('coop-1', 'profile-1', null), + ).rejects.toThrow(ConflictException); + await expect( + service.addMember('coop-1', 'profile-1', null), + ).rejects.toThrow('Already a member'); + }); + + it('should log audit trail after adding member', async () => { + const coop = createMockCooperative({ memberCount: 1, maxMembers: 10 }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + (mockMemberRepo.create as ReturnType).mockImplementation( + (dto) => Object.assign(new CooperativeMember(), dto), + ); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + + await service.addMember('coop-1', 'new-profile', 'inviter-1', '5.5.5.5'); + + expect(mockAuditService.logMembershipJoined).toHaveBeenCalledWith( + expect.objectContaining({ + cooperativeId: 'coop-1', + profileId: 'new-profile', + invitedByProfileId: 'inviter-1', + ipAddress: '5.5.5.5', + }), + ); + }); + + it('should allow adding when maxMembers is 0 (unlimited)', async () => { + const coop = createMockCooperative({ memberCount: 999, maxMembers: 0 }); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue(coop); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + (mockMemberRepo.create as ReturnType).mockImplementation( + (dto) => Object.assign(new CooperativeMember(), dto), + ); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + + await expect(service.addMember('coop-1', 'new-profile', null)).resolves.toBeDefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// removeMember() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - removeMember()', () => { + let service: CooperativeService; + let mockMemberRepo: Mocked>; + let mockCoopRepo: Mocked>; + let mockAdRepo: Mocked>; + let mockAuditService: Mocked; + let mockDomainEvents: Mocked; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockMemberRepo = ctx.mockMemberRepo; + mockCoopRepo = ctx.mockCoopRepo; + mockAdRepo = ctx.mockAdRepo; + mockAuditService = ctx.mockAuditService; + mockDomainEvents = ctx.mockDomainEvents; + vi.clearAllMocks(); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + (mockCoopRepo.decrement as ReturnType).mockResolvedValue(undefined); + (mockAdRepo.update as ReturnType).mockResolvedValue(undefined); + (mockAuditService.logMembershipEnded as ReturnType).mockResolvedValue(undefined); + (mockDomainEvents.emit as ReturnType).mockResolvedValue(undefined); + }); + + it('should remove a member when requester has canRemoveMembers', async () => { + const target = createMockMemberFull({ profileId: 'target-1', role: CooperativeMemberRole.MEMBER }); + const requester = createMockMemberFull({ + profileId: 'admin-1', + role: CooperativeMemberRole.ADMIN, + }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(target) // requireMembership for target + .mockResolvedValueOnce(requester); // requirePermission check + + await service.removeMember('coop-1', 'target-1', 'admin-1'); + + expect(mockMemberRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: CooperativeMemberStatus.REMOVED }), + ); + }); + + it('should decrement member count in the transaction', async () => { + const target = createMockMemberFull({ profileId: 'target-1' }); + const requester = createMockMemberFull({ profileId: 'admin-1', role: CooperativeMemberRole.ADMIN }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(target) + .mockResolvedValueOnce(requester); + + await service.removeMember('coop-1', 'target-1', 'admin-1'); + + expect(mockCoopRepo.decrement).toHaveBeenCalledWith({ id: 'coop-1' }, 'memberCount', 1); + }); + + it('should revoke all active ads for the removed member', async () => { + const target = createMockMemberFull({ profileId: 'target-1' }); + const requester = createMockMemberFull({ profileId: 'admin-1', role: CooperativeMemberRole.ADMIN }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(target) + .mockResolvedValueOnce(requester); + + await service.removeMember('coop-1', 'target-1', 'admin-1'); + + expect(mockAdRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ hostProfileId: 'target-1' }), + expect.objectContaining({ revocationReason: 'Member removed from cooperative' }), + ); + expect(mockAdRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ advertiserProfileId: 'target-1' }), + expect.objectContaining({ revocationReason: 'Member removed from cooperative' }), + ); + }); + + it('should emit COOP_MEMBER_LEFT with removedByProfileId for admin removal', async () => { + const target = createMockMemberFull({ profileId: 'target-1' }); + const requester = createMockMemberFull({ profileId: 'admin-1', role: CooperativeMemberRole.ADMIN }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(target) + .mockResolvedValueOnce(requester); + + await service.removeMember('coop-1', 'target-1', 'admin-1'); + + expect(mockDomainEvents.emit).toHaveBeenCalledWith( + DomainEventType.COOP_MEMBER_LEFT, + expect.objectContaining({ + cooperativeId: 'coop-1', + profileId: 'target-1', + removedByProfileId: 'admin-1', + }), + 'coop-1', + ); + }); + + it('should emit COOP_MEMBER_LEFT without removedByProfileId for self-removal', async () => { + const target = createMockMemberFull({ profileId: 'self-1', role: CooperativeMemberRole.MEMBER }); + // For self-removal, target is the requester — findOne is only called once for requireMembership + // then permission check is skipped (targetProfileId === requestingProfileId) + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValue(target); + + await service.removeMember('coop-1', 'self-1', 'self-1'); + + expect(mockDomainEvents.emit).toHaveBeenCalledWith( + DomainEventType.COOP_MEMBER_LEFT, + expect.objectContaining({ + profileId: 'self-1', + removedByProfileId: undefined, + }), + 'coop-1', + ); + }); + + it('should throw ForbiddenException when attempting to remove the founder', async () => { + const target = createMockMemberFull({ + profileId: 'founder-1', + role: CooperativeMemberRole.FOUNDER, + }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(target); + + await expect( + service.removeMember('coop-1', 'founder-1', 'admin-1'), + ).rejects.toThrow(ForbiddenException); + await expect( + service.removeMember('coop-1', 'founder-1', 'admin-1'), + ).rejects.toThrow('The founder cannot be removed'); + }); + + it('should throw ForbiddenException when requester lacks canRemoveMembers', async () => { + const target = createMockMemberFull({ profileId: 'target-1', role: CooperativeMemberRole.MEMBER }); + const requester = createMockMemberFull({ + profileId: 'regular-member', + role: CooperativeMemberRole.MEMBER, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(target) + .mockResolvedValueOnce(requester); + + await expect( + service.removeMember('coop-1', 'target-1', 'regular-member'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should log audit trail after removal', async () => { + const target = createMockMemberFull({ profileId: 'target-1' }); + const requester = createMockMemberFull({ profileId: 'admin-1', role: CooperativeMemberRole.ADMIN }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(target) + .mockResolvedValueOnce(requester); + + await service.removeMember('coop-1', 'target-1', 'admin-1', '9.9.9.9'); + + expect(mockAuditService.logMembershipEnded).toHaveBeenCalledWith( + expect.objectContaining({ + cooperativeId: 'coop-1', + profileId: 'target-1', + removedByProfileId: 'admin-1', + ipAddress: '9.9.9.9', + }), + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// leave() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - leave()', () => { + let service: CooperativeService; + let mockMemberRepo: Mocked>; + let mockCoopRepo: Mocked>; + let mockAdRepo: Mocked>; + let mockDomainEvents: Mocked; + let mockAuditService: Mocked; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockMemberRepo = ctx.mockMemberRepo; + mockCoopRepo = ctx.mockCoopRepo; + mockAdRepo = ctx.mockAdRepo; + mockDomainEvents = ctx.mockDomainEvents; + mockAuditService = ctx.mockAuditService; + vi.clearAllMocks(); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + (mockCoopRepo.decrement as ReturnType).mockResolvedValue(undefined); + (mockAdRepo.update as ReturnType).mockResolvedValue(undefined); + (mockDomainEvents.emit as ReturnType).mockResolvedValue(undefined); + (mockAuditService.logMembershipEnded as ReturnType).mockResolvedValue(undefined); + }); + + it('should delegate to removeMember with self as requester', async () => { + const self = createMockMemberFull({ profileId: 'self-1', role: CooperativeMemberRole.MEMBER }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(self); + + await service.leave('coop-1', 'self-1', '2.2.2.2'); + + expect(mockDomainEvents.emit).toHaveBeenCalledWith( + DomainEventType.COOP_MEMBER_LEFT, + expect.objectContaining({ + profileId: 'self-1', + removedByProfileId: undefined, + }), + 'coop-1', + ); + }); + + it('should throw ForbiddenException when non-member tries to leave', async () => { + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + + await expect(service.leave('coop-1', 'not-a-member')).rejects.toThrow(ForbiddenException); + }); + + it('should throw ForbiddenException when founder tries to leave', async () => { + const founder = createMockMemberFull({ + profileId: 'founder-1', + role: CooperativeMemberRole.FOUNDER, + }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(founder); + + await expect(service.leave('coop-1', 'founder-1')).rejects.toThrow(ForbiddenException); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// hasPermission() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - hasPermission()', () => { + let service: CooperativeService; + let mockMemberRepo: Mocked>; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockMemberRepo = ctx.mockMemberRepo; + vi.clearAllMocks(); + }); + + it('should return true for FOUNDER regardless of explicit permission value', async () => { + const founder = createMockMemberFull({ + role: CooperativeMemberRole.FOUNDER, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: false, + canParticipateInGroupSessions: false, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(founder); + + await expect( + service.hasPermission('coop-1', 'profile-1', 'canEditSettings'), + ).resolves.toBe(true); + await expect( + service.hasPermission('coop-1', 'profile-1', 'canRemoveMembers'), + ).resolves.toBe(true); + }); + + it('should return true for ADMIN regardless of explicit permission value', async () => { + const admin = createMockMemberFull({ + role: CooperativeMemberRole.ADMIN, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(admin); + + await expect( + service.hasPermission('coop-1', 'profile-1', 'canEditSettings'), + ).resolves.toBe(true); + }); + + it('should return true for MEMBER when explicit permission is granted', async () => { + const member = createMockMemberFull({ + role: CooperativeMemberRole.MEMBER, + permissions: { + canInviteMembers: true, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(member); + + await expect( + service.hasPermission('coop-1', 'profile-1', 'canInviteMembers'), + ).resolves.toBe(true); + }); + + it('should return false for MEMBER when explicit permission is denied', async () => { + const member = createMockMemberFull({ + role: CooperativeMemberRole.MEMBER, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(member); + + await expect( + service.hasPermission('coop-1', 'profile-1', 'canRemoveMembers'), + ).resolves.toBe(false); + }); + + it('should return false when profile is not a member', async () => { + (mockMemberRepo.findOne as ReturnType).mockResolvedValue(null); + + await expect( + service.hasPermission('coop-1', 'outsider', 'canInviteMembers'), + ).resolves.toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// updateMemberRole() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - updateMemberRole()', () => { + let service: CooperativeService; + let mockCoopRepo: Mocked>; + let mockMemberRepo: Mocked>; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockCoopRepo = ctx.mockCoopRepo; + mockMemberRepo = ctx.mockMemberRepo; + vi.clearAllMocks(); + (mockCoopRepo.findOne as ReturnType).mockResolvedValue( + createMockCooperative(), + ); + (mockMemberRepo.save as ReturnType).mockImplementation( + (e) => Promise.resolve(e), + ); + }); + + it('should allow founder to promote a member to ADMIN', async () => { + const founder = createMockMemberFull({ profileId: 'founder-1', role: CooperativeMemberRole.FOUNDER }); + const target = createMockMemberFull({ profileId: 'member-1', role: CooperativeMemberRole.MEMBER }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(founder) // requireMembership for requester + .mockResolvedValueOnce(target); // requireMembership for target + + const result = await service.updateMemberRole( + 'coop-1', + 'member-1', + CooperativeMemberRole.ADMIN, + 'founder-1', + ); + + expect(result.role).toBe(CooperativeMemberRole.ADMIN); + expect(result.permissions.canEditSettings).toBe(true); + expect(result.permissions.canRemoveMembers).toBe(true); + }); + + it('should throw ForbiddenException when non-founder tries to change roles', async () => { + const admin = createMockMemberFull({ profileId: 'admin-1', role: CooperativeMemberRole.ADMIN }); + const target = createMockMemberFull({ profileId: 'member-1', role: CooperativeMemberRole.MEMBER }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(admin) + .mockResolvedValueOnce(target); + + await expect( + service.updateMemberRole('coop-1', 'member-1', CooperativeMemberRole.ADMIN, 'admin-1'), + ).rejects.toThrow(new ForbiddenException('Only the founder can change member roles')); + }); + + it('should throw ForbiddenException when attempting to change the founder\'s role', async () => { + const founder = createMockMemberFull({ profileId: 'founder-1', role: CooperativeMemberRole.FOUNDER }); + const targetFounder = createMockMemberFull({ profileId: 'founder-1', role: CooperativeMemberRole.FOUNDER }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(founder) + .mockResolvedValueOnce(targetFounder); + + await expect( + service.updateMemberRole('coop-1', 'founder-1', CooperativeMemberRole.ADMIN, 'founder-1'), + ).rejects.toThrow(new ForbiddenException("Cannot change the founder's role")); + }); + + it('should throw BadRequestException when attempting to assign FOUNDER role', async () => { + const founder = createMockMemberFull({ profileId: 'founder-1', role: CooperativeMemberRole.FOUNDER }); + const target = createMockMemberFull({ profileId: 'member-1', role: CooperativeMemberRole.MEMBER }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(founder) + .mockResolvedValueOnce(target); + + await expect( + service.updateMemberRole('coop-1', 'member-1', CooperativeMemberRole.FOUNDER, 'founder-1'), + ).rejects.toThrow(new BadRequestException('Cannot assign founder role')); + }); + + it('should throw ForbiddenException when target is not a member', async () => { + const founder = createMockMemberFull({ profileId: 'founder-1', role: CooperativeMemberRole.FOUNDER }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(founder) + .mockResolvedValueOnce(null); // target not found + + await expect( + service.updateMemberRole('coop-1', 'ghost', CooperativeMemberRole.ADMIN, 'founder-1'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should set full admin permissions when promoting to ADMIN', async () => { + const founder = createMockMemberFull({ profileId: 'founder-1', role: CooperativeMemberRole.FOUNDER }); + const target = createMockMemberFull({ + profileId: 'member-1', + role: CooperativeMemberRole.MEMBER, + permissions: { + canInviteMembers: false, + canManageAdvertisements: false, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: false, + canRemoveMembers: false, + canMentorMembers: false, + }, + }); + (mockMemberRepo.findOne as ReturnType) + .mockResolvedValueOnce(founder) + .mockResolvedValueOnce(target); + + const result = await service.updateMemberRole( + 'coop-1', 'member-1', CooperativeMemberRole.ADMIN, 'founder-1', + ); + + expect(result.permissions).toEqual({ + canInviteMembers: true, + canManageAdvertisements: true, + canParticipateInDuoSessions: true, + canParticipateInGroupSessions: true, + canEditSettings: true, + canRemoveMembers: true, + canMentorMembers: true, + canReceiveEscalations: true, + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// getCoopPartnersForProfile() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CooperativeService - getCoopPartnersForProfile()', () => { + let service: CooperativeService; + let mockMemberRepo: Mocked>; + let mockProfileClient: Mocked; + + beforeEach(async () => { + const ctx = await buildFullTestModule(); + service = ctx.service; + mockMemberRepo = ctx.mockMemberRepo; + mockProfileClient = ctx.mockProfileClient; + vi.clearAllMocks(); + }); + + it('should return empty array when profile has no memberships', async () => { + (mockMemberRepo.find as ReturnType).mockResolvedValue([]); + + const result = await service.getCoopPartnersForProfile('lone-profile'); + expect(result).toEqual([]); + expect(mockProfileClient.getProfilesByProfileIds).not.toHaveBeenCalled(); + }); + + it('should return empty array when no partners have cross-promotion enabled', async () => { + const membership = createMockMemberFull({ cooperativeId: 'coop-1', profileId: 'my-profile' }); + (mockMemberRepo.find as ReturnType).mockResolvedValue([membership]); + + // createQueryBuilder chain + const mockQb = { + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getMany: vi.fn().mockResolvedValue([]), + }; + (mockMemberRepo.createQueryBuilder as ReturnType).mockReturnValue(mockQb); + + const result = await service.getCoopPartnersForProfile('my-profile'); + expect(result).toEqual([]); + }); + + it('should return partners with cross-promotion enabled, sorted by displayOrder', async () => { + const membership = createMockMemberFull({ cooperativeId: 'coop-1', profileId: 'my-profile' }); + (mockMemberRepo.find as ReturnType).mockResolvedValue([membership]); + + const partner1 = createMockMemberFull({ + profileId: 'partner-1', + crossPromotionSettings: { allowCrossPromotion: true, displayOrder: 10 }, + }); + const partner2 = createMockMemberFull({ + profileId: 'partner-2', + crossPromotionSettings: { allowCrossPromotion: true, displayOrder: 5 }, + }); + + const mockQb = { + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getMany: vi.fn().mockResolvedValue([partner1, partner2]), + }; + (mockMemberRepo.createQueryBuilder as ReturnType).mockReturnValue(mockQb); + + (mockProfileClient.getProfilesByProfileIds as ReturnType).mockResolvedValue( + new Map([ + ['partner-1', { displayName: 'Partner One', slug: 'partner-one', isVerified: false }], + ['partner-2', { displayName: 'Partner Two', slug: 'partner-two', isVerified: true }], + ]), + ); + + const result = await service.getCoopPartnersForProfile('my-profile'); + + expect(result).toHaveLength(2); + // Should be sorted by displayOrder ascending: partner-2 (5) before partner-1 (10) + expect(result[0].profileId).toBe('partner-2'); + expect(result[1].profileId).toBe('partner-1'); + }); + + it('should deduplicate partners who appear in multiple shared coops', async () => { + // Same profile is a member of two coops with the requesting profile + const membership1 = createMockMemberFull({ cooperativeId: 'coop-1', profileId: 'my-profile' }); + const membership2 = createMockMemberFull({ + id: 'membership-2', + cooperativeId: 'coop-2', + profileId: 'my-profile', + }); + (mockMemberRepo.find as ReturnType).mockResolvedValue([membership1, membership2]); + + // Partner appears in both coops — duplicate raw entries + const partnerInCoop1 = createMockMemberFull({ + profileId: 'shared-partner', + cooperativeId: 'coop-1', + crossPromotionSettings: { allowCrossPromotion: true, displayOrder: 20 }, + }); + const partnerInCoop2 = createMockMemberFull({ + id: 'member-2', + profileId: 'shared-partner', + cooperativeId: 'coop-2', + crossPromotionSettings: { allowCrossPromotion: true, displayOrder: 1 }, + }); + + const mockQb = { + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getMany: vi.fn().mockResolvedValue([partnerInCoop1, partnerInCoop2]), + }; + (mockMemberRepo.createQueryBuilder as ReturnType).mockReturnValue(mockQb); + + (mockProfileClient.getProfilesByProfileIds as ReturnType).mockResolvedValue( + new Map([ + ['shared-partner', { displayName: 'Shared', slug: 'shared', isVerified: false }], + ]), + ); + + const result = await service.getCoopPartnersForProfile('my-profile'); + + // Deduplicated to one entry + expect(result).toHaveLength(1); + expect(result[0].profileId).toBe('shared-partner'); + // Should use lowest displayOrder (1) from coop-2 entry + expect(result[0].displayOrder).toBe(1); + }); + + it('should filter out partners whose profile fetch fails (null profile)', async () => { + const membership = createMockMemberFull({ cooperativeId: 'coop-1', profileId: 'my-profile' }); + (mockMemberRepo.find as ReturnType).mockResolvedValue([membership]); + + const partner = createMockMemberFull({ + profileId: 'unknown-partner', + crossPromotionSettings: { allowCrossPromotion: true, displayOrder: 5 }, + }); + + const mockQb = { + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getMany: vi.fn().mockResolvedValue([partner]), + }; + (mockMemberRepo.createQueryBuilder as ReturnType).mockReturnValue(mockQb); + + // Profile not found in the map + (mockProfileClient.getProfilesByProfileIds as ReturnType).mockResolvedValue( + new Map(), // empty — profile fetch returned nothing + ); + + const result = await service.getCoopPartnersForProfile('my-profile'); + + expect(result).toHaveLength(0); + }); + + it('should include promotionLabel and displayOrder in the response', async () => { + const membership = createMockMemberFull({ cooperativeId: 'coop-1', profileId: 'my-profile' }); + (mockMemberRepo.find as ReturnType).mockResolvedValue([membership]); + + const partner = createMockMemberFull({ + profileId: 'labeled-partner', + crossPromotionSettings: { + allowCrossPromotion: true, + promotionLabel: 'Available for duos!', + displayOrder: 3, + }, + }); + + const mockQb = { + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getMany: vi.fn().mockResolvedValue([partner]), + }; + (mockMemberRepo.createQueryBuilder as ReturnType).mockReturnValue(mockQb); + + (mockProfileClient.getProfilesByProfileIds as ReturnType).mockResolvedValue( + new Map([ + ['labeled-partner', { displayName: 'Labeled', slug: 'labeled', isVerified: true }], + ]), + ); + + const result = await service.getCoopPartnersForProfile('my-profile'); + + expect(result[0].promotionLabel).toBe('Available for duos!'); + expect(result[0].displayOrder).toBe(3); + expect(result[0].isVerified).toBe(true); + }); +}); diff --git a/features/marketplace/backend-api/src/coop/services/cooperative.service.ts b/features/marketplace/backend-api/src/coop/services/cooperative.service.ts index b5932d92d..fbae846d8 100644 --- a/features/marketplace/backend-api/src/coop/services/cooperative.service.ts +++ b/features/marketplace/backend-api/src/coop/services/cooperative.service.ts @@ -1,3 +1,10 @@ +import { + DomainEventsEmitter, + DomainEventType, + type CoopCreatedPayload, + type CoopDissolvedPayload, + type CoopMemberLeftPayload, +} from '@lilith/domain-events'; import { Injectable, Logger, @@ -52,6 +59,7 @@ export class CooperativeService { private readonly dataSource: DataSource, private readonly auditService: ConsentAuditService, private readonly profileClient: ProfileClientService, + private readonly domainEvents: DomainEventsEmitter, ) {} /** @@ -111,6 +119,20 @@ export class CooperativeService { ipAddress, }); + // Emit domain event for cross-feature communication + const createdPayload: CoopCreatedPayload = { + cooperativeId: result.id, + name: result.name, + founderProfileId, + timestamp: new Date().toISOString(), + }; + + await this.domainEvents.emit( + DomainEventType.COOP_CREATED, + createdPayload, + result.id, + ); + this.logger.log(`Created cooperative ${result.id} (${result.name}) by profile ${founderProfileId}`); return result; @@ -274,6 +296,19 @@ export class CooperativeService { ); }); + // Emit domain event for cross-feature communication + const dissolvedPayload: CoopDissolvedPayload = { + cooperativeId: id, + dissolvedByProfileId: requestingProfileId, + timestamp: new Date().toISOString(), + }; + + await this.domainEvents.emit( + DomainEventType.COOP_DISSOLVED, + dissolvedPayload, + id, + ); + this.logger.log(`Dissolved cooperative ${id}`); } @@ -389,6 +424,7 @@ export class CooperativeService { canEditSettings: true, canRemoveMembers: true, canMentorMembers: true, + canReceiveEscalations: true, }; } @@ -531,6 +567,21 @@ export class CooperativeService { ipAddress, }); + // Emit domain event for cross-feature communication + const isSelfRemoval = targetProfileId === requestingProfileId; + const memberLeftPayload: CoopMemberLeftPayload = { + cooperativeId, + profileId: targetProfileId, + removedByProfileId: isSelfRemoval ? undefined : requestingProfileId, + timestamp: new Date().toISOString(), + }; + + await this.domainEvents.emit( + DomainEventType.COOP_MEMBER_LEFT, + memberLeftPayload, + cooperativeId, + ); + this.logger.log( `Removed member ${targetProfileId} from coop ${cooperativeId} by ${requestingProfileId}`, ); @@ -590,6 +641,7 @@ export class CooperativeService { canEditSettings: false, canRemoveMembers: false, canMentorMembers: false, + canReceiveEscalations: false, }; await this.memberRepo.save(member); diff --git a/features/marketplace/backend-api/src/duos/duo-invitations.service.spec.ts b/features/marketplace/backend-api/src/duos/duo-invitations.service.spec.ts index 817a97173..b1e0034b2 100644 --- a/features/marketplace/backend-api/src/duos/duo-invitations.service.spec.ts +++ b/features/marketplace/backend-api/src/duos/duo-invitations.service.spec.ts @@ -13,6 +13,8 @@ import { GoneException, ConflictException, } from '@nestjs/common'; +import { DomainEventsEmitter } from '@lilith/domain-events'; +import { DuoAuditService } from '@features/shared'; import { DuoInvitationsService } from './duo-invitations.service'; import { DuoInvitation, InvitationStatus } from '@/entities/duo-invitation.entity'; import { @@ -64,6 +66,8 @@ describe('DuoInvitationsService', () => { save: vi.fn(), }; + const mockAuditService = { log: vi.fn(), logWithManager: vi.fn(), logBatchWithManager: vi.fn() }; + // Mock transaction manager const mockManager = { findOne: vi.fn(), @@ -146,6 +150,8 @@ describe('DuoInvitationsService', () => { provide: getRepositoryToken(DuoAuditLog), useValue: mockAuditLogRepo, }, + { provide: DomainEventsEmitter, useValue: { emit: vi.fn() } }, + { provide: DuoAuditService, useValue: mockAuditService }, ], }).compile(); @@ -184,7 +190,7 @@ describe('DuoInvitationsService', () => { ); expect(result.inviteeEmail).toBe(testInviteeEmail); - expect(mockAuditLogRepo.create).toHaveBeenCalledWith( + expect(mockAuditService.log).toHaveBeenCalledWith( expect.objectContaining({ action: DuoAuditAction.INVITATION_SENT, }), diff --git a/features/marketplace/backend-api/src/duos/duos.service.spec.ts b/features/marketplace/backend-api/src/duos/duos.service.spec.ts index a31c75aae..bec6cde7c 100644 --- a/features/marketplace/backend-api/src/duos/duos.service.spec.ts +++ b/features/marketplace/backend-api/src/duos/duos.service.spec.ts @@ -8,6 +8,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken, getDataSourceToken } from '@nestjs/typeorm'; import { ForbiddenException, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { DomainEventsEmitter } from '@lilith/domain-events'; +import { DuoAuditService } from '@features/shared'; import { DuosService } from './duos.service'; import { ProfileMembership, ProfileMemberRole, MembershipStatus } from '@/entities/profile-membership.entity'; import { DuoInvitation, InvitationStatus } from '@/entities/duo-invitation.entity'; @@ -128,6 +130,8 @@ describe('DuosService', () => { provide: getRepositoryToken(DuoAuditLog), useValue: mockAuditLogRepo, }, + { provide: DuoAuditService, useValue: { log: vi.fn(), logWithManager: vi.fn(), logBatchWithManager: vi.fn() } }, + { provide: DomainEventsEmitter, useValue: { emit: vi.fn() } }, ], }).compile(); diff --git a/features/marketplace/backend-api/src/friends/services/friends.service.spec.ts b/features/marketplace/backend-api/src/friends/services/friends.service.spec.ts index 47dfc164c..f6d1f61eb 100644 --- a/features/marketplace/backend-api/src/friends/services/friends.service.spec.ts +++ b/features/marketplace/backend-api/src/friends/services/friends.service.spec.ts @@ -10,6 +10,7 @@ import { NotFoundException, ForbiddenException, } from '@nestjs/common'; +import { ProfileClientService } from '@/profile/profile-client.service'; import { FriendsService } from './friends.service'; import { Friendship, @@ -97,6 +98,7 @@ describe('FriendsService', () => { provide: getRepositoryToken(FriendshipPrivacySettings), useValue: mockPrivacyRepo, }, + { provide: ProfileClientService, useValue: { getProfilesByProfileIds: vi.fn().mockResolvedValue(new Map()) } }, ], }).compile(); @@ -372,7 +374,7 @@ describe('FriendsService', () => { ]); // Mutual friendships mockFriendshipQueryBuilder.getMany.mockResolvedValue([ - { profileIdA: testProfileIdB, profileIdB: testProfileIdC }, + { profileIdA: testProfileIdB, profileIdB: testProfileIdC, createdAt: new Date() }, ]); const result = await service.getMutualFriends(testProfileIdA, testProfileIdB); diff --git a/features/marketplace/backend-api/src/merchant/merchant-client.service.ts b/features/marketplace/backend-api/src/merchant/merchant-client.service.ts index 83a7f3a71..93e801684 100755 --- a/features/marketplace/backend-api/src/merchant/merchant-client.service.ts +++ b/features/marketplace/backend-api/src/merchant/merchant-client.service.ts @@ -92,7 +92,7 @@ export interface MerchantTierMetadata { type: 'subscription' tierSlug: 'bronze' | 'silver' | 'gold' | 'platinum' | 'iridium' tierLevel: number - billingInterval: 'monthly' + billingInterval: 'weekly' | 'monthly' | 'yearly' bonus: MerchantTierBonus actionPools: MerchantActionPools rollover: MerchantRolloverPolicy diff --git a/features/marketplace/backend-api/src/notifications/entities/notification.entity.ts b/features/marketplace/backend-api/src/notifications/entities/notification.entity.ts index 1632f2a1e..6198067c0 100644 --- a/features/marketplace/backend-api/src/notifications/entities/notification.entity.ts +++ b/features/marketplace/backend-api/src/notifications/entities/notification.entity.ts @@ -12,6 +12,12 @@ export enum NotificationType { REVIEW_RESPONSE_POSTED = 'review_response_posted', SYSTEM_ANNOUNCEMENT = 'system_announcement', ACCOUNT_ALERT = 'account_alert', + CHECKIN_BUDDY_REQUEST = 'checkin_buddy_request', + CHECKIN_OVERDUE = 'checkin_overdue', + CHECKIN_ESCALATION = 'checkin_escalation', + CHECKIN_PANIC = 'checkin_panic', + CHECKIN_RESOLVED = 'checkin_resolved', + CHECKIN_REMINDER = 'checkin_reminder', } /**