import { SessionCommunicationService } from './session-communication.service';
import { CommunicationTypeEnum } from 'src/common/enum/communication-type.enum';
import { ModeOfOperationEnum } from 'src/common/enum/mode-of-operation.enum';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import { CommunicationTemplateAccessKeyEnum } from 'src/common/enum/communication-template-access-key.enum';
import { BulkCommunicationSelectionModeEnum } from 'src/common/enum/bulk-communication-selection-mode.enum';
import { SessionOccurrence } from './session-communication.constants';
import { PROGRAM_TYPE_KEYS } from 'src/common/constants/string-constants';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import InifniConflictException from 'src/common/exceptions/infini-conflict-exception';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';

// Rethrow custom exceptions unchanged so error-path assertions stay hermetic
// (avoids the AppLoggerService singleton that the real handler touches).
jest.mock('src/common/utils/handle-error.util', () => ({
  handleKnownErrors: (_code: string, error: any) => {
    throw error;
  },
}));

// A non-S3 attachment url is fetched over HTTP rather than from the bucket; stub that so the
// non-S3 value-card path stays hermetic. `delay` is re-exported untouched (real module otherwise).
jest.mock('src/common/utils/common.util', () => ({
  ...jest.requireActual('src/common/utils/common.util'),
  fetchPdfAsBase64: jest.fn().mockResolvedValue(Buffer.from('external-bytes').toString('base64')),
}));

const onlineSession = {
  id: 12,
  programId: 5,
  modeOfOperation: ModeOfOperationEnum.ONLINE,
  emailSenderAddress: 'noreply@infinitheism.com',
  emailSenderName: 'Infinitheism',
  // TAT program — only TAT distinguishes the final session.
  program: { type: { key: 'PT_TAT' } },
};

const recipient = {
  id: 101,
  emailAddress: 'seeker@example.com',
  mobileNumber: '+919999999999',
  fullName: 'Test Seeker',
  programId: 5,
};

function buildService() {
  // Default: single-session program, so occurrence resolves to REGULAR.
  const sessionRepo = {
    findOne: jest.fn(),
    find: jest.fn().mockResolvedValue([{ id: 12 }]),
    update: jest.fn().mockResolvedValue(undefined),
  };
  const programRepo = {
    findOne: jest.fn().mockResolvedValue({
      id: 5,
      emailSenderAddress: 'program@example.com',
      emailSenderName: 'Program Sender',
    }),
  };
  const repository = {
    getWelcomeRecipients: jest.fn().mockResolvedValue([recipient]),
    getInviteRecipients: jest.fn().mockResolvedValue([recipient]),
    getFinalInviteRecipients: jest.fn().mockResolvedValue([recipient]),
    getAbsentRecipients: jest.fn().mockResolvedValue([recipient]),
    getValueCardRecipients: jest.fn().mockResolvedValue([recipient]),
    getProgramCompletionRecipients: jest.fn().mockResolvedValue([recipient]),
    getCommonInviteRecipients: jest.fn().mockResolvedValue([
      {
        userId: 99,
        emailAddress: 'staff@example.com',
        mobileNumber: '919999999999',
        displayName: 'Staff Member',
        joinUrl: 'https://zoom.us/j/555',
        meetingId: '555',
        meetingPasscode: 'pass',
      },
    ]),
    getSystemPlaceholderLinks: jest
      .fn()
      .mockResolvedValue([{ name: 'System Link 1', joinUrl: 'https://zoom.us/j/sys1' }]),
    getGeneralLinkRecipients: jest.fn().mockResolvedValue([
      {
        userId: null,
        emailAddress: 'general@example.com',
        mobileNumber: '919999999998',
        displayName: 'General Link Recipient',
        joinUrl: 'https://zoom.us/j/777',
        meetingId: '777',
        meetingPasscode: 'genpass',
      },
    ]),
    getGeneralLinkRecipientById: jest.fn().mockResolvedValue({
      userId: null,
      emailAddress: 'general@example.com',
      mobileNumber: '919999999998',
      displayName: 'General Link Recipient',
      joinUrl: 'https://zoom.us/j/777',
      meetingId: '777',
      meetingPasscode: 'genpass',
      programSessionId: 777,
    }),
    getSummary: jest.fn(),
    recordSendStatus: jest.fn().mockResolvedValue(undefined),
    findTriggeredPurposesBySessions: jest.fn(),
    // Default: the session's bulk value card already went out, so the single top-up is allowed.
    hasTriggeredBulkSend: jest.fn().mockResolvedValue(true),
    // Default false: most tests exercise a FIRST Common-Invite / System-Links send.
    hasTriggeredScopedBulkSend: jest.fn().mockResolvedValue(false),
  };
  const templatesRepository = {
    findByProgramAndAccessKey: jest.fn().mockResolvedValue({ id: 10 }),
  };
  const mergeDataService = {
    getTemplateWithMergeInfo: jest
      .fn()
      .mockResolvedValue({ templateKey: 'tmpl_key', mergeInfo: { reg_fullname: 'Test Seeker' } }),
  };
  const emailQueueService = {
    isQueueEnabled: jest.fn().mockReturnValue(true),
    queueEmail: jest.fn().mockResolvedValue('email-msg-id'),
    queueBulkEmail: jest.fn().mockResolvedValue(['bulk-email-msg-id']),
  };
  const whatsAppQueueService = {
    isQueueEnabled: jest.fn().mockReturnValue(true),
    queueWhatsAppMessage: jest.fn().mockResolvedValue('wa-msg-id'),
    queueBulkWhatsApp: jest.fn().mockResolvedValue(['bulk-wa-msg-id']),
  };
  const communicationService = {
    sendBulkEmail: jest.fn().mockResolvedValue(undefined),
    sendSingleEmail: jest.fn().mockResolvedValue(undefined),
    sendTemplateMessage: jest.fn().mockResolvedValue(undefined),
    sendBulkTemplateMessage: jest.fn().mockResolvedValue(undefined),
  };
  const awsS3Service = {
    extractS3KeyFromUrl: jest.fn().mockReturnValue('value-cards/session-12/card.pptx'),
    getS3ObjectAsBuffer: jest.fn().mockResolvedValue(Buffer.from('ppt-bytes')),
    uploadToS3: jest
      .fn()
      .mockResolvedValue('https://s3.amazonaws.com/bucket/valuecard/sessions/12/card.pptx'),
  };
  const userRepository = {
    getUsersByRoleKeys: jest
      .fn()
      .mockResolvedValue([{ id: 77, fullName: 'Admin User', email: 'admin@example.com' }]),
  };
  const logger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };

  const service = new SessionCommunicationService(
    sessionRepo as any,
    programRepo as any,
    repository as any,
    templatesRepository as any,
    mergeDataService as any,
    emailQueueService as any,
    whatsAppQueueService as any,
    communicationService as any,
    awsS3Service as any,
    userRepository as any,
    logger as any,
  );

  return {
    service,
    sessionRepo,
    programRepo,
    repository,
    templatesRepository,
    mergeDataService,
    emailQueueService,
    whatsAppQueueService,
    communicationService,
    awsS3Service,
  };
}

/**
 * The /bulk endpoint returns on acceptance, so its response carries no dispatch counts — the real
 * outcome is the hdb_session_communication_status row the background pass writes. These read that
 * row (after `whenBackgroundWorkSettles`) wherever a test used to assert on `result.enqueued`.
 */
function recordedStatus(repository: { recordSendStatus: jest.Mock }) {
  const calls = repository.recordSendStatus.mock.calls;
  return calls.length ? calls[calls.length - 1][0] : null;
}

function expectAccepted(result: any, requested = 1) {
  expect(result).toMatchObject({
    accepted: true,
    requested,
    enqueued: { email: 0, whatsapp: 0 },
    skipped: [],
  });
}

describe('SessionCommunicationService', () => {
  const bulkDto = {
    programId: 5,
    sessionId: 12,
    purpose: SessionCommunicationPurposeEnum.ABSENT,
    selectionMode: BulkCommunicationSelectionModeEnum.ALL,
  };

  it('rejects sends for a non-online session', async () => {
    const { service, sessionRepo } = buildService();
    sessionRepo.findOne.mockResolvedValue({
      ...onlineSession,
      modeOfOperation: ModeOfOperationEnum.OFFLINE,
    });

    await expect(service.sendBulk(bulkDto as any, 1, { background: true })).rejects.toBeInstanceOf(
      InifniBadRequestException,
    );
  });

  it('rejects when the session does not exist', async () => {
    const { service, sessionRepo } = buildService();
    sessionRepo.findOne.mockResolvedValue(null);

    await expect(service.sendBulk(bulkDto as any, 1, { background: true })).rejects.toBeInstanceOf(
      InifniNotFoundException,
    );
  });

  it('rejects a bulk send with no eligible recipients', async () => {
    const { service, sessionRepo, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    repository.getAbsentRecipients.mockResolvedValue([]);

    await expect(service.sendBulk(bulkDto as any, 1, { background: true })).rejects.toBeInstanceOf(
      InifniBadRequestException,
    );
  });

  it('ABSENT bulk enqueues email as a batch and whatsapp as a bulk message', async () => {
    const { service, sessionRepo, emailQueueService, whatsAppQueueService, repository } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    const result = await service.sendBulk(bulkDto as any, 7, { background: true });
    await service.whenBackgroundWorkSettles();

    // Bulk send → bulk methods: one provider-side email batch + one WATI bulk message.
    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(emailQueueService.queueEmail).not.toHaveBeenCalled();
    expect(whatsAppQueueService.queueBulkWhatsApp).toHaveBeenCalledTimes(1);
    expect(whatsAppQueueService.queueWhatsAppMessage).not.toHaveBeenCalled();
    // The response is an acceptance; the dispatch counts live on the status row.
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({
      emailEnqueuedCount: 1,
      whatsappEnqueuedCount: 1,
      skippedCount: 0,
    });

    // The email batch carries the templateId + actor for tracking, and one recipient.
    const bulkArg = emailQueueService.queueBulkEmail.mock.calls[0][0];
    expect(bulkArg).toMatchObject({ templateId: 10, createdBy: 7 });
    expect(bulkArg.recipients).toEqual([
      expect.objectContaining({ emailAddress: 'seeker@example.com', registrationId: 101 }),
    ]);

    // The WhatsApp bulk message carries the template/actor + per-recipient registrationId.
    const waArg = whatsAppQueueService.queueBulkWhatsApp.mock.calls[0][0];
    expect(waArg).toMatchObject({ templateId: 10, createdBy: 7 });
    expect(waArg.recipients).toEqual([expect.objectContaining({ registrationId: 101 })]);
  });

  it('records a TRIGGERED status row (per program + session + purpose) after a bulk send', async () => {
    const { service, sessionRepo, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);

    await service.sendBulk(bulkDto as any, 7, { background: true });
    await service.whenBackgroundWorkSettles();

    expect(repository.recordSendStatus).toHaveBeenCalledTimes(1);
    expect(repository.recordSendStatus).toHaveBeenCalledWith(
      expect.objectContaining({
        programId: 5,
        sessionId: 12,
        purpose: SessionCommunicationPurposeEnum.ABSENT,
        occurrence: SessionOccurrence.REGULAR,
        status: 'TRIGGERED',
        requestedCount: 1,
        emailEnqueuedCount: 1,
        whatsappEnqueuedCount: 1,
        skippedCount: 0,
        createdBy: 7,
      }),
    );
  });

  it('records a SKIPPED status row when nothing is dispatched', async () => {
    const { service, sessionRepo, mergeDataService, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // No template configured for the program → every channel is skipped, nothing enqueued.
    mergeDataService.getTemplateWithMergeInfo.mockResolvedValue({ templateKey: null });

    await service.sendBulk(bulkDto as any, 7, { background: true });
    await service.whenBackgroundWorkSettles();

    expect(repository.recordSendStatus).toHaveBeenCalledWith(
      expect.objectContaining({
        status: 'SKIPPED',
        emailEnqueuedCount: 0,
        whatsappEnqueuedCount: 0,
      }),
    );
  });

  it('program-level status row carries a null sessionId', async () => {
    const { service, repository } = buildService();

    await service.triggerProgramCommunication(5, SessionCommunicationPurposeEnum.WELCOME, 0);

    expect(repository.recordSendStatus).toHaveBeenCalledWith(
      expect.objectContaining({
        programId: 5,
        sessionId: null,
        purpose: SessionCommunicationPurposeEnum.WELCOME,
        createdBy: null,
      }),
    );
  });

  it('email falls back to a direct send when the queue is disabled', async () => {
    const { service, sessionRepo, emailQueueService, communicationService, repository } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    emailQueueService.isQueueEnabled.mockReturnValue(false);

    const result = await service.sendBulk(bulkDto as any, 7, { background: true });
    await service.whenBackgroundWorkSettles();

    // Not enqueued — sent directly via the communication service, still counted + tracked.
    expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    expect(communicationService.sendBulkEmail).toHaveBeenCalledTimes(1);
    const directArg = communicationService.sendBulkEmail.mock.calls[0][0];
    expect(directArg).toMatchObject({
      templateKey: 'tmpl_key',
      trackinfo: { templateId: 10, createdBy: 7 },
    });
    expect(directArg.to).toEqual([
      expect.objectContaining({ emailAddress: 'seeker@example.com', registrationId: 101 }),
    ]);
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({ emailEnqueuedCount: 1, skippedCount: 0 });
  });

  it('email falls back to a direct send when the enqueue returns no message id', async () => {
    const { service, sessionRepo, emailQueueService, communicationService, repository } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    emailQueueService.queueBulkEmail.mockResolvedValue([]); // enqueue produced nothing

    const result = await service.sendBulk(bulkDto as any, 1, { background: true });
    await service.whenBackgroundWorkSettles();

    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(communicationService.sendBulkEmail).toHaveBeenCalledTimes(1);
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({ emailEnqueuedCount: 1 });
  });

  it('bulk whatsapp falls back to a direct bulk send when the queue is disabled', async () => {
    const { service, sessionRepo, whatsAppQueueService, communicationService, repository } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    whatsAppQueueService.isQueueEnabled.mockReturnValue(false);

    const result = await service.sendBulk(bulkDto as any, 7, { background: true });
    await service.whenBackgroundWorkSettles();

    // Bulk send → direct fallback uses the WATI bulk method, not the single one.
    expect(whatsAppQueueService.queueBulkWhatsApp).not.toHaveBeenCalled();
    expect(communicationService.sendTemplateMessage).not.toHaveBeenCalled();
    expect(communicationService.sendBulkTemplateMessage).toHaveBeenCalledTimes(1);
    const waArg = communicationService.sendBulkTemplateMessage.mock.calls[0][0];
    expect(waArg).toMatchObject({
      templateName: 'tmpl_key',
      trackinfo: { templateId: 10, createdBy: 7 },
    });
    expect(waArg.recipients).toEqual([
      expect.objectContaining({ whatsappNumber: '+919999999999', registrationId: 101 }),
    ]);
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({ whatsappEnqueuedCount: 1 });
  });

  // /bulk is accepted, not completed: the audience is resolved and counted in-request (so an
  // empty audience is still a 400 and the caller learns how many people it will reach), then the
  // per-recipient merge resolution + enqueue — the part that scales with the audience and was
  // timing out as a 502 — runs off-request.
  it('returns before dispatching, with the audience size and accepted:true', async () => {
    const { service, sessionRepo, emailQueueService, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);

    const result = await service.sendBulk(bulkDto as any, 7, { background: true });

    // Nothing dispatched or recorded yet at the moment the caller gets its response.
    expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    expect(repository.recordSendStatus).not.toHaveBeenCalled();
    expectAccepted(result);

    await service.whenBackgroundWorkSettles();
    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(repository.recordSendStatus).toHaveBeenCalledTimes(1);
  });

  it('records a SKIPPED status row when the background dispatch throws', async () => {
    const { service, sessionRepo, mergeDataService, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    mergeDataService.getTemplateWithMergeInfo.mockRejectedValue(new Error('merge blew up'));

    // The caller already has its 200 — a background failure must not reject, and must leave a
    // trace rather than looking like the send was never triggered.
    const result = await service.sendBulk(bulkDto as any, 7, { background: true });
    await service.whenBackgroundWorkSettles();

    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({
      status: 'SKIPPED',
      requestedCount: 1,
      emailEnqueuedCount: 0,
      whatsappEnqueuedCount: 0,
    });
  });

  // The direct fallback is the slow path by construction — sendBulkTemplateMessage walks the
  // recipients 10 at a time with a 2s pause — so it must not hold the background task open
  // either; it is fired and awaited only by the drain.
  it('does not wait for the direct provider send to finish before returning', async () => {
    const { service, sessionRepo, whatsAppQueueService, communicationService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    whatsAppQueueService.isQueueEnabled.mockReturnValue(false);

    let releaseProvider: () => void = () => undefined;
    const providerCall = new Promise<void>((resolve) => {
      releaseProvider = resolve;
    });
    communicationService.sendBulkTemplateMessage.mockReturnValue(providerCall);

    const result = await service.sendBulk(bulkDto as any, 7, { background: true });
    expectAccepted(result);

    releaseProvider();
    await service.whenBackgroundWorkSettles();
    expect(communicationService.sendBulkTemplateMessage).toHaveBeenCalledTimes(1);
  });

  // The scheduled path (SQS -> SessionCommunicationTriggerProcessor -> triggerProgramCommunication)
  // must stay inline: the processor marks the queue message handled when this returns, so a
  // backgrounded failure would delete the message and silently lose the scheduled send.
  it('triggerProgramCommunication dispatches inline and propagates failures', async () => {
    const { service, mergeDataService, emailQueueService, repository } = buildService();

    // Completed, not accepted — real counts, and the status row already written on return.
    const result = await service.triggerProgramCommunication(
      5,
      SessionCommunicationPurposeEnum.WELCOME,
      0,
    );
    expect(result?.accepted).toBeUndefined();
    expect(result?.enqueued).toEqual({ email: 1, whatsapp: 1 });
    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(repository.recordSendStatus).toHaveBeenCalledTimes(1);

    mergeDataService.getTemplateWithMergeInfo.mockRejectedValue(new Error('merge blew up'));
    await expect(
      service.triggerProgramCommunication(5, SessionCommunicationPurposeEnum.WELCOME, 0),
    ).rejects.toBeDefined();
  });

  it('ABSENT narrows recipients by attendance for the target session', async () => {
    const { service, sessionRepo, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);

    await service.sendBulk(bulkDto as any, 1, { background: true });
    await service.whenBackgroundWorkSettles();

    // sessionId (12) is threaded so the repository can exclude attendees of that session.
    expect(repository.getAbsentRecipients).toHaveBeenCalledWith(
      5,
      12,
      BulkCommunicationSelectionModeEnum.ALL,
      undefined,
    );
  });

  it('triggerProgramCommunication (WELCOME) sends program-level without a session id', async () => {
    const { service, sessionRepo, programRepo, repository } = buildService();

    await service.triggerProgramCommunication(5, SessionCommunicationPurposeEnum.WELCOME, 0);

    // Program-level: resolves the program, never loads/resolves a session.
    expect(programRepo.findOne).toHaveBeenCalledTimes(1);
    expect(sessionRepo.findOne).not.toHaveBeenCalled();
    expect(repository.getWelcomeRecipients).toHaveBeenCalledTimes(1);
  });

  it('triggerProgramCommunication (PROGRAM_COMPLETION) sends program-level without a session id', async () => {
    const { service, sessionRepo, programRepo, repository } = buildService();

    await service.triggerProgramCommunication(
      5,
      SessionCommunicationPurposeEnum.PROGRAM_COMPLETION,
      0,
    );

    expect(programRepo.findOne).toHaveBeenCalledTimes(1);
    expect(sessionRepo.findOne).not.toHaveBeenCalled();
    expect(repository.getProgramCompletionRecipients).toHaveBeenCalledTimes(1);
  });

  it('PROGRAM_COMPLETION for a normal program targets every eligible registration', async () => {
    const { service, programRepo, repository } = buildService();
    // No program type → non-TAT.
    programRepo.findOne.mockResolvedValue({
      id: 5,
      emailSenderAddress: 'program@example.com',
      emailSenderName: 'Program Sender',
    });

    await service.triggerProgramCommunication(
      5,
      SessionCommunicationPurposeEnum.PROGRAM_COMPLETION,
      0,
    );

    // Called with NO finalSessionId (4th arg) → whole eligible audience.
    expect(repository.getProgramCompletionRecipients).toHaveBeenCalledWith(
      5,
      BulkCommunicationSelectionModeEnum.ALL,
      undefined,
    );
  });

  it('PROGRAM_COMPLETION for a TAT program targets only final-session attendees', async () => {
    const { service, sessionRepo, programRepo, repository } = buildService();
    programRepo.findOne.mockResolvedValue({
      id: 5,
      emailSenderAddress: 'program@example.com',
      emailSenderName: 'Program Sender',
      type: { key: PROGRAM_TYPE_KEYS.TAT },
    });
    // Program sessions ordered; the last is the final session (id 13).
    sessionRepo.find.mockResolvedValue([{ id: 12 }, { id: 13 }]);

    await service.triggerProgramCommunication(
      5,
      SessionCommunicationPurposeEnum.PROGRAM_COMPLETION,
      0,
    );

    // Called WITH the final session id → attendance-gated audience.
    expect(repository.getProgramCompletionRecipients).toHaveBeenCalledWith(
      5,
      BulkCommunicationSelectionModeEnum.ALL,
      undefined,
      13,
    );
  });

  it('program-level send does not thread a sessionId into the merge context', async () => {
    const { service, mergeDataService } = buildService();

    await service.triggerProgramCommunication(5, SessionCommunicationPurposeEnum.WELCOME, 0);

    // extraMergeContext (6th arg) must be empty — no session to resolve session-scoped fields.
    for (const call of mergeDataService.getTemplateWithMergeInfo.mock.calls) {
      expect(call[5]).toEqual({});
    }
  });

  it('VALUE_CARD sends email only (no whatsapp)', async () => {
    const { service, sessionRepo, emailQueueService, whatsAppQueueService, repository } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    const result = await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.VALUE_CARD } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(whatsAppQueueService.queueWhatsAppMessage).not.toHaveBeenCalled();
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({
      emailEnqueuedCount: 1,
      whatsappEnqueuedCount: 0,
    });
  });

  it('sendValueCardBulk queues the batch with S3-reference attachments + description (no whatsapp)', async () => {
    const {
      service,
      sessionRepo,
      repository,
      mergeDataService,
      emailQueueService,
      whatsAppQueueService,
      communicationService,
      awsS3Service,
    } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);

    const result = await service.sendValueCardBulk(
      {
        programId: 5,
        sessionId: 12,
        selectionMode: BulkCommunicationSelectionModeEnum.ALL,
        description: 'Value card body',
        attachmentUrls: ['https://s3.amazonaws.com/bucket/value-cards/session-12/card.pptx'],
      } as any,
      7,
    );

    // Value-card audience, email-only, and it stays on the queue: the bulk message carries the
    // files as S3 references, which BulkEmailProcessor downloads before sending.
    expect(repository.getValueCardRecipients).toHaveBeenCalledTimes(1);
    expect(whatsAppQueueService.queueWhatsAppMessage).not.toHaveBeenCalled();
    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(communicationService.sendBulkEmail).not.toHaveBeenCalled();
    expect(result.enqueued).toEqual({ email: 1, whatsapp: 0 });

    // References, never bytes — that's what keeps the batch message under the SQS 256 KB limit.
    // toEqual (not toMatchObject) is the point: the base64 IS available here, having been read for
    // the re-upload, and must still be dropped from the message rather than ride along.
    const queuedBatch = emailQueueService.queueBulkEmail.mock.calls[0][0];
    expect(queuedBatch.attachments).toEqual([
      {
        name: 'card.pptx',
        s3Key: 'valuecard/sessions/12/card.pptx',
        contentType:
          'application/vnd.openxmlformats-officedocument.presentationml.presentation',
      },
    ]);
    expect(queuedBatch.attachments[0]).not.toHaveProperty('content');

    // description is threaded into the merge context (6th arg) alongside the sessionId.
    const mergeCtx = mergeDataService.getTemplateWithMergeInfo.mock.calls[0][5];
    expect(mergeCtx).toMatchObject({ sessionId: 12, description: 'Value card body' });

    // The source file is still fetched once here — it has to be, in order to be re-uploaded.
    expect(awsS3Service.getS3ObjectAsBuffer).toHaveBeenCalledTimes(1);

    // The file is re-uploaded under valuecard/sessions/<id>/<filename>.
    expect(awsS3Service.uploadToS3).toHaveBeenCalledWith(
      'valuecard/sessions/12/card.pptx',
      expect.any(Buffer),
      'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    );

    // The description + uploaded URLs are stored on the session in the single value_card_details column.
    expect(sessionRepo.update).toHaveBeenCalledWith(
      { id: 12 },
      {
        valueCardDetails: {
          description: 'Value card body',
          documentUrls: ['https://s3.amazonaws.com/bucket/valuecard/sessions/12/card.pptx'],
        },
      },
    );
  });

  it('sendValueCardBulk falls back to a direct send WITH bytes attached when the enqueue fails', async () => {
    const { service, sessionRepo, emailQueueService, communicationService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // queueBulkEmail swallows SQS errors and returns [] — treated as a failed enqueue.
    emailQueueService.queueBulkEmail.mockResolvedValue([]);

    const result = await service.sendValueCardBulk(
      {
        programId: 5,
        sessionId: 12,
        selectionMode: BulkCommunicationSelectionModeEnum.ALL,
        description: 'Value card body',
        attachmentUrls: ['https://s3.amazonaws.com/bucket/value-cards/session-12/card.pptx'],
      } as any,
      7,
    );

    // The fallback must not send an attachment-less batch: the bytes were already fetched for the
    // re-upload, so they are attached here directly.
    expect(communicationService.sendBulkEmail).toHaveBeenCalledTimes(1);
    expect(result.enqueued).toEqual({ email: 1, whatsapp: 0 });
    const bulkArg = communicationService.sendBulkEmail.mock.calls[0][0];
    expect(bulkArg.attachments).toEqual([
      expect.objectContaining({
        name: 'card.pptx',
        content: expect.any(String),
        mime_type:
          'application/vnd.openxmlformats-officedocument.presentationml.presentation',
      }),
    ]);
  });

  describe('sendValueCardSingle', () => {
    /** A session carrying the description + file urls a prior bulk run stored. */
    const sessionWithValueCard = {
      ...onlineSession,
      valueCardDetails: {
        description: 'Value card body',
        documentUrls: ['https://s3.amazonaws.com/bucket/valuecard/sessions/12/card.pptx'],
      },
    };
    const singleDto = { programId: 5, sessionId: 12, registrationId: 101 } as any;

    it("queues one email using the session's STORED description + files, by S3 reference", async () => {
      const {
        service,
        sessionRepo,
        repository,
        mergeDataService,
        emailQueueService,
        whatsAppQueueService,
        communicationService,
        awsS3Service,
      } = buildService();
      sessionRepo.findOne.mockResolvedValue(sessionWithValueCard);

      const result = await service.sendValueCardSingle(singleDto, 7);

      // Email only, single recipient, and it goes through the SINGLE email queue — the single-email
      // message supports attachments, unlike the bulk one the bulk value card has to bypass.
      expect(whatsAppQueueService.queueWhatsAppMessage).not.toHaveBeenCalled();
      expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
      expect(emailQueueService.queueEmail).toHaveBeenCalledTimes(1);
      expect(communicationService.sendSingleEmail).not.toHaveBeenCalled();
      expect(result.enqueued).toEqual({ email: 1, whatsapp: 0 });

      // Scoped to exactly this registration, through the same audience rule bulk uses.
      expect(repository.getValueCardRecipients).toHaveBeenCalledWith(
        5,
        12,
        BulkCommunicationSelectionModeEnum.SELECTED,
        [101],
      );

      // Body text comes from the stored details, not from the request.
      expect(mergeDataService.getTemplateWithMergeInfo.mock.calls[0][5]).toMatchObject({
        sessionId: 12,
        description: 'Value card body',
      });

      // The attachment rides as an S3 REFERENCE, never as bytes — that's what keeps the SQS
      // message under the 256 KB limit. EmailProcessor downloads it before sending.
      const queued = emailQueueService.queueEmail.mock.calls[0][0];
      expect(queued.attachments).toEqual([
        {
          name: 'card.pptx',
          s3Key: 'value-cards/session-12/card.pptx',
          contentType:
            'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        },
      ]);
      // Explicitly: no base64 on the queue. Bytes belong on the consumer side.
      expect(queued.attachments[0]).not.toHaveProperty('content');
      // So the file is never downloaded on this path...
      expect(awsS3Service.getS3ObjectAsBuffer).not.toHaveBeenCalled();
      // ...never re-uploaded (it already lives at that key from the bulk run)...
      expect(awsS3Service.uploadToS3).not.toHaveBeenCalled();
      // ...and the stored details are left exactly as the bulk run wrote them.
      expect(sessionRepo.update).not.toHaveBeenCalled();
    });

    it('falls back to a direct send WITH the bytes attached when the email queue is disabled', async () => {
      const { service, sessionRepo, emailQueueService, communicationService, awsS3Service } =
        buildService();
      sessionRepo.findOne.mockResolvedValue(sessionWithValueCard);
      emailQueueService.isQueueEnabled.mockReturnValue(false);

      const result = await service.sendValueCardSingle(singleDto, 7);

      expect(emailQueueService.queueEmail).not.toHaveBeenCalled();
      expect(communicationService.sendSingleEmail).toHaveBeenCalledTimes(1);
      expect(result.enqueued).toEqual({ email: 1, whatsapp: 0 });

      // The direct path needs real bytes, so NOW the file is downloaded and attached as base64.
      expect(awsS3Service.getS3ObjectAsBuffer).toHaveBeenCalledTimes(1);
      const emailArg = communicationService.sendSingleEmail.mock.calls[0][0];
      expect(emailArg.attachments).toEqual([
        expect.objectContaining({ name: 'card.pptx', content: expect.any(String) }),
      ]);
    });

    it('falls back to a direct send WITH the bytes attached when the enqueue fails', async () => {
      const { service, sessionRepo, emailQueueService, communicationService, awsS3Service } =
        buildService();
      sessionRepo.findOne.mockResolvedValue(sessionWithValueCard);
      // queueEmail swallows SQS errors and returns null — treated as a failed enqueue.
      emailQueueService.queueEmail.mockResolvedValue(null);

      const result = await service.sendValueCardSingle(singleDto, 7);

      expect(communicationService.sendSingleEmail).toHaveBeenCalledTimes(1);
      expect(result.enqueued).toEqual({ email: 1, whatsapp: 0 });
      // The fallback must not send an attachment-less email — the bytes are fetched lazily here.
      expect(awsS3Service.getS3ObjectAsBuffer).toHaveBeenCalledTimes(1);
      const emailArg = communicationService.sendSingleEmail.mock.calls[0][0];
      expect(emailArg.attachments).toEqual([
        expect.objectContaining({ name: 'card.pptx', content: expect.any(String) }),
      ]);
    });

    it('never puts base64 on the queue: a non-S3 stored url forces the whole send direct', async () => {
      const { service, sessionRepo, emailQueueService, communicationService, awsS3Service } =
        buildService();
      sessionRepo.findOne.mockResolvedValue({
        ...onlineSession,
        valueCardDetails: {
          description: 'Value card body',
          documentUrls: [
            'https://s3.amazonaws.com/bucket/valuecard/sessions/12/card.pptx',
            'https://example.com/external/deck.pptx',
          ],
        },
      });
      // Only the first url resolves to an S3 key; the external one does not.
      awsS3Service.extractS3KeyFromUrl.mockImplementation((url: string) =>
        url.includes('example.com') ? null : 'value-cards/session-12/card.pptx',
      );

      await service.sendValueCardSingle(singleDto, 7);

      // One un-referenceable file takes the WHOLE send off the queue. The alternatives are both
      // wrong: queueing a partial set drops a file silently, and inlining its base64 wastes queue
      // space and risks the 256 KB message cap.
      expect(emailQueueService.queueEmail).not.toHaveBeenCalled();
      expect(communicationService.sendSingleEmail).toHaveBeenCalledTimes(1);
      // Direct send, so both files are present as real bytes.
      const emailArg = communicationService.sendSingleEmail.mock.calls[0][0];
      expect(emailArg.attachments).toEqual([
        expect.objectContaining({ name: 'card.pptx', content: expect.any(String) }),
        expect.objectContaining({ name: 'deck.pptx', content: expect.any(String) }),
      ]);
    });

    it('a non-S3 file keeps a BULK batch off the queue — bulk messages carry references only', async () => {
      const { service, sessionRepo, emailQueueService, communicationService, awsS3Service } =
        buildService();
      sessionRepo.findOne.mockResolvedValue(onlineSession);
      // The upload target key is what the bulk path references, so make the re-upload land on a
      // url that does NOT resolve back to a key — the un-referenceable case.
      awsS3Service.extractS3KeyFromUrl.mockReturnValue(null);

      await service.sendValueCardBulk(
        {
          programId: 5,
          sessionId: 12,
          selectionMode: BulkCommunicationSelectionModeEnum.ALL,
          description: 'Value card body',
          attachmentUrls: ['https://example.com/external/deck.pptx'],
        } as any,
        7,
      );

      // prepareValueCardFiles always re-uploads, so it always has a key — the batch still queues.
      expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
      expect(communicationService.sendBulkEmail).not.toHaveBeenCalled();
      const queued = emailQueueService.queueBulkEmail.mock.calls[0][0];
      expect(queued.attachments).toEqual([
        expect.objectContaining({ s3Key: 'valuecard/sessions/12/deck.pptx' }),
      ]);
    });

    it('is rejected when no bulk value card has been sent for the session', async () => {
      const { service, sessionRepo, repository, communicationService } = buildService();
      sessionRepo.findOne.mockResolvedValue(sessionWithValueCard);
      repository.hasTriggeredBulkSend.mockResolvedValue(false);

      await expect(service.sendValueCardSingle(singleDto, 7)).rejects.toBeInstanceOf(
        InifniBadRequestException,
      );

      expect(repository.hasTriggeredBulkSend).toHaveBeenCalledWith(
        5,
        12,
        SessionCommunicationPurposeEnum.VALUE_CARD,
      );
      expect(communicationService.sendSingleEmail).not.toHaveBeenCalled();
    });

    it('is rejected when the session has no stored value card details', async () => {
      const { service, sessionRepo, communicationService } = buildService();
      sessionRepo.findOne.mockResolvedValue({ ...onlineSession, valueCardDetails: null });

      await expect(service.sendValueCardSingle(singleDto, 7)).rejects.toBeInstanceOf(
        InifniBadRequestException,
      );
      expect(communicationService.sendSingleEmail).not.toHaveBeenCalled();
    });

    it('is rejected when the stored details have a description but no file urls', async () => {
      const { service, sessionRepo, communicationService } = buildService();
      sessionRepo.findOne.mockResolvedValue({
        ...onlineSession,
        valueCardDetails: { description: 'Value card body', documentUrls: [] },
      });

      await expect(service.sendValueCardSingle(singleDto, 7)).rejects.toBeInstanceOf(
        InifniBadRequestException,
      );
      expect(communicationService.sendSingleEmail).not.toHaveBeenCalled();
    });

    it('is rejected when the registration is not an applicable value-card recipient', async () => {
      const { service, sessionRepo, repository, communicationService } = buildService();
      sessionRepo.findOne.mockResolvedValue(sessionWithValueCard);
      repository.getValueCardRecipients.mockResolvedValue([]);

      await expect(service.sendValueCardSingle(singleDto, 7)).rejects.toBeInstanceOf(
        InifniBadRequestException,
      );
      expect(communicationService.sendSingleEmail).not.toHaveBeenCalled();
    });

    it('does not record a status row — that table tracks bulk runs only', async () => {
      const { service, sessionRepo, repository } = buildService();
      sessionRepo.findOne.mockResolvedValue(sessionWithValueCard);

      await service.sendValueCardSingle(singleDto, 7);

      expect(repository.recordSendStatus).not.toHaveBeenCalled();
    });
  });

  describe('general-link ("pre-test") value card', () => {
    const bulkDto = {
      programId: 5,
      sessionId: 12,
      description: 'Pre-test value card body',
      attachmentUrls: ['https://s3.amazonaws.com/bucket/pre-test/session-12/card.pptx'],
    } as any;
    const singleDto = { programId: 5, generatedLinkId: 4210 } as any;

    it('bulk stores the details in pretest_value_card_details, NOT value_card_details', async () => {
      const { service, sessionRepo, awsS3Service } = buildService();
      sessionRepo.findOne.mockResolvedValue(onlineSession);
      awsS3Service.uploadToS3.mockResolvedValue(
        'https://s3.amazonaws.com/bucket/pretestvaluecard/sessions/12/card.pptx',
      );

      await service.sendGeneralLinkValueCardBulk(bulkDto, 7);

      // Its own column — a seeker value card for the same session must not be overwritten.
      expect(sessionRepo.update).toHaveBeenCalledWith(
        { id: 12 },
        {
          pretestValueCardDetails: {
            description: 'Pre-test value card body',
            documentUrls: ['https://s3.amazonaws.com/bucket/pretestvaluecard/sessions/12/card.pptx'],
          },
        },
      );
      // Files are re-hosted under their own prefix, not the seeker one.
      expect(awsS3Service.uploadToS3).toHaveBeenCalledWith(
        'pretestvaluecard/sessions/12/card.pptx',
        expect.any(Buffer),
        expect.any(String),
      );
    });

    it('bulk queues the batch by S3 reference, with the description merged in', async () => {
      const { service, sessionRepo, emailQueueService, communicationService, mergeDataService } =
        buildService();
      sessionRepo.findOne.mockResolvedValue(onlineSession);

      const result = await service.sendGeneralLinkValueCardBulk(bulkDto, 7);

      expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
      expect(communicationService.sendBulkEmail).not.toHaveBeenCalled();
      expect(result.enqueued.email).toBe(1);

      const queued = emailQueueService.queueBulkEmail.mock.calls[0][0];
      expect(queued.attachments).toEqual([
        expect.objectContaining({ s3Key: 'pretestvaluecard/sessions/12/card.pptx' }),
      ]);
      // No base64 on the queue, same rule as every other send here.
      expect(queued.attachments[0]).not.toHaveProperty('content');
      expect(mergeDataService.getTemplateWithMergeInfo.mock.calls[0][5]).toMatchObject({
        description: 'Pre-test value card body',
      });
    });

    it("single reads the session's stored pretest details and writes nothing", async () => {
      const { service, sessionRepo, emailQueueService, awsS3Service } = buildService();
      sessionRepo.findOne.mockResolvedValue({
        ...onlineSession,
        pretestValueCardDetails: {
          description: 'Pre-test value card body',
          documentUrls: ['https://s3.amazonaws.com/bucket/pretestvaluecard/sessions/12/card.pptx'],
        },
      });

      await service.sendGeneralLinkValueCardSingle(singleDto, 7);

      expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
      const queued = emailQueueService.queueBulkEmail.mock.calls[0][0];
      expect(queued.attachments).toEqual([
        expect.objectContaining({ s3Key: 'value-cards/session-12/card.pptx' }),
      ]);
      // A top-up reads only — it never re-stores or re-uploads.
      expect(sessionRepo.update).not.toHaveBeenCalled();
      expect(awsS3Service.uploadToS3).not.toHaveBeenCalled();
    });

    it('single is rejected when no general-link bulk value card has been sent', async () => {
      const { service, sessionRepo, repository, emailQueueService } = buildService();
      sessionRepo.findOne.mockResolvedValue({
        ...onlineSession,
        pretestValueCardDetails: {
          description: 'Pre-test value card body',
          documentUrls: ['https://s3.amazonaws.com/bucket/pretestvaluecard/sessions/12/card.pptx'],
        },
      });
      repository.hasTriggeredBulkSend.mockResolvedValue(false);

      await expect(service.sendGeneralLinkValueCardSingle(singleDto, 7)).rejects.toBeInstanceOf(
        InifniBadRequestException,
      );
      // Gated on the GENERAL-LINK purpose specifically — a seeker value card run must not unlock it.
      expect(repository.hasTriggeredBulkSend).toHaveBeenCalledWith(
        5,
        777,
        SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
      );
      expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    });

    it('single is rejected when the session has no stored pretest details', async () => {
      const { service, sessionRepo, emailQueueService } = buildService();
      sessionRepo.findOne.mockResolvedValue({
        ...onlineSession,
        pretestValueCardDetails: null,
      });

      await expect(service.sendGeneralLinkValueCardSingle(singleDto, 7)).rejects.toBeInstanceOf(
        InifniBadRequestException,
      );
      expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    });
  });

  it('PROGRAM_COMPLETION sends on both email and whatsapp', async () => {
    const { service, sessionRepo, repository, emailQueueService, whatsAppQueueService } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    const result = await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.PROGRAM_COMPLETION } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    expect(repository.getProgramCompletionRecipients).toHaveBeenCalledTimes(1);
    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(whatsAppQueueService.queueBulkWhatsApp).toHaveBeenCalledTimes(1);
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({
      emailEnqueuedCount: 1,
      whatsappEnqueuedCount: 1,
    });
  });

  it('skips a channel (and does not enqueue) when the template is not configured', async () => {
    const { service, sessionRepo, mergeDataService, emailQueueService, repository } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    mergeDataService.getTemplateWithMergeInfo.mockResolvedValue(null);

    const result = await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.VALUE_CARD } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    expectAccepted(result);
    // Nothing dispatched → the background pass records the run as SKIPPED, with the per-recipient
    // skip counted. The reason string itself is asserted by the single-send tests, which still
    // return a completed SendResult.
    expect(recordedStatus(repository)).toMatchObject({
      status: 'SKIPPED',
      emailEnqueuedCount: 0,
      skippedCount: 1,
    });
  });

  it('skips a channel when the recipient has no contact for it', async () => {
    const { service, sessionRepo, repository, whatsAppQueueService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    repository.getAbsentRecipients.mockResolvedValue([{ ...recipient, mobileNumber: null }]);

    const result = await service.sendBulk(bulkDto as any, 1, { background: true });
    await service.whenBackgroundWorkSettles();

    expect(whatsAppQueueService.queueWhatsAppMessage).not.toHaveBeenCalled();
    expectAccepted(result);
    // Email went out, WhatsApp was skipped for want of a number — visible on the status row.
    expect(recordedStatus(repository)).toMatchObject({
      status: 'TRIGGERED',
      emailEnqueuedCount: 1,
      whatsappEnqueuedCount: 0,
      skippedCount: 1,
    });
  });

  it('single send rejects when the registration is not an applicable recipient', async () => {
    const { service, sessionRepo, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Not applicable for INVITE (e.g. no provisioned link) → the purpose filter returns nothing.
    repository.getInviteRecipients.mockResolvedValue([]);

    await expect(
      service.sendSingle(
        {
          programId: 5,
          sessionId: 12,
          registrationId: 999,
          purpose: SessionCommunicationPurposeEnum.INVITE,
        } as any,
        1,
      ),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
  });

  it('single send uses the single methods (queueEmail + queueWhatsAppMessage), not the bulk ones', async () => {
    const { service, sessionRepo, repository, emailQueueService, whatsAppQueueService } =
      buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    repository.getInviteRecipients.mockResolvedValue([recipient]);

    const result = await service.sendSingle(
      {
        programId: 5,
        sessionId: 12,
        registrationId: 101,
        purpose: SessionCommunicationPurposeEnum.INVITE,
      } as any,
      7,
    );

    // Single send → single methods.
    expect(emailQueueService.queueEmail).toHaveBeenCalledTimes(1);
    expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    expect(whatsAppQueueService.queueWhatsAppMessage).toHaveBeenCalledTimes(1);
    expect(whatsAppQueueService.queueBulkWhatsApp).not.toHaveBeenCalled();
    expect(result.enqueued).toEqual({ email: 1, whatsapp: 1 });

    // The single email carries per-recipient tracking metadata.
    const emailMeta = emailQueueService.queueEmail.mock.calls[0][0].metadata;
    expect(emailMeta).toMatchObject({ registrationId: 101, templateId: 10, createdBy: 7 });
  });

  it('INVITE for a non-last session resolves REGULAR and uses the regular template', async () => {
    const { service, sessionRepo, mergeDataService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Three sessions; target (id 12) is not the last -> REGULAR.
    sessionRepo.find.mockResolvedValue([{ id: 11 }, { id: 12 }, { id: 13 }]);
    await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.INVITE } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_REGULAR_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_REGULAR_SESSION_WATI_SEEKER,
    );
  });

  it('INVITE for the last session resolves FINAL and uses the final template', async () => {
    const { service, sessionRepo, mergeDataService, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Three sessions; target (id 12) is the last -> FINAL.
    sessionRepo.find.mockResolvedValue([{ id: 10 }, { id: 11 }, { id: 12 }]);
    await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.INVITE } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_FINAL_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_FINAL_SESSION_WATI_SEEKER,
    );
    // The FINAL invite restricts to seekers who attended every earlier session — the
    // attended-all-priors recipient query, not the plain eligible set.
    expect(repository.getFinalInviteRecipients).toHaveBeenCalled();
  });

  it('INVITE for the last session of a non-TAT program stays REGULAR', async () => {
    const { service, sessionRepo, mergeDataService } = buildService();
    // Non-TAT program — the final session is NOT distinguished.
    sessionRepo.findOne.mockResolvedValue({
      ...onlineSession,
      program: { type: { key: 'PT_HDBMSD' } },
    });
    // Target (id 12) is the last session, but program type is not TAT.
    sessionRepo.find.mockResolvedValue([{ id: 10 }, { id: 11 }, { id: 12 }]);
    await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.INVITE } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_REGULAR_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_FINAL_SESSION_EMAIL_SEEKER,
    );
  });

  it('ABSENT for the penultimate session resolves PRE_FINAL and uses the pre-final template', async () => {
    const { service, sessionRepo, mergeDataService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Four sessions; target (id 12) is the last but one -> PRE_FINAL.
    sessionRepo.find.mockResolvedValue([{ id: 10 }, { id: 11 }, { id: 12 }, { id: 13 }]);
    await service.sendBulk(bulkDto as any, 1, { background: true });
    await service.whenBackgroundWorkSettles();

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.ABSENT_PRE_FINAL_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.ABSENT_PRE_FINAL_SESSION_WATI_SEEKER,
    );
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.ABSENT_REGULAR_SESSION_EMAIL_SEEKER,
    );
  });

  it('ABSENT for the last session resolves FINAL, not PRE_FINAL', async () => {
    const { service, sessionRepo, mergeDataService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Four sessions; target (id 12) is the last -> FINAL.
    sessionRepo.find.mockResolvedValue([{ id: 9 }, { id: 10 }, { id: 11 }, { id: 12 }]);
    await service.sendBulk(bulkDto as any, 1, { background: true });
    await service.whenBackgroundWorkSettles();

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.ABSENT_FINAL_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.ABSENT_PRE_FINAL_SESSION_EMAIL_SEEKER,
    );
  });

  it('INVITE for the penultimate session falls back to REGULAR (no pre-final Invite template)', async () => {
    const { service, sessionRepo, mergeDataService } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Four sessions; target (id 12) is the last but one, but INVITE has no PRE_FINAL variant.
    sessionRepo.find.mockResolvedValue([{ id: 10 }, { id: 11 }, { id: 12 }, { id: 13 }]);
    await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.INVITE } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_REGULAR_SESSION_EMAIL_SEEKER,
    );
    // Never resolves a (non-existent) pre-final Invite access key.
    expect(accessKeysUsed.every((key: string) => !String(key).includes('PRE_FINAL'))).toBe(true);
  });

  it('WELCOME is occurrence-independent: email + whatsapp, no session-order lookup', async () => {
    const {
      service,
      sessionRepo,
      mergeDataService,
      emailQueueService,
      whatsAppQueueService,
      repository,
    } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    const result = await service.sendBulk(
      { ...bulkDto, purpose: SessionCommunicationPurposeEnum.WELCOME } as any,
      1,
      { background: true },
    );
    await service.whenBackgroundWorkSettles();

    // Welcome does not depend on session position, so no ordering query is run.
    expect(sessionRepo.find).not.toHaveBeenCalled();
    expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    expect(whatsAppQueueService.queueBulkWhatsApp).toHaveBeenCalledTimes(1);
    expectAccepted(result);
    expect(recordedStatus(repository)).toMatchObject({
      emailEnqueuedCount: 1,
      whatsappEnqueuedCount: 1,
    });

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.WELCOME_EMAIL_SEEKER);
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.WELCOME_WATI_SEEKER);
  });

  // Both of these announce a link that does not change, so a second run would just re-mail the same
  // thing. The scope is part of the send's identity: program-wide and per-session are separate runs.
  describe('once-only guard (Common Invite / System Links)', () => {
    it('refuses a COMMON_INVITE that was already triggered for the same scope', async () => {
      const { service, repository, emailQueueService, whatsAppQueueService } = buildService();
      repository.hasTriggeredScopedBulkSend.mockResolvedValue(true);

      await expect(service.sendCommonInviteBulk(5, 1)).rejects.toBeInstanceOf(
        InifniConflictException,
      );

      expect(repository.hasTriggeredScopedBulkSend).toHaveBeenCalledWith(
        5,
        null, // program-level run
        SessionCommunicationPurposeEnum.COMMON_INVITE,
      );
      // Refused before any work: no recipient query, no dispatch, no status row.
      expect(repository.getCommonInviteRecipients).not.toHaveBeenCalled();
      expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
      expect(whatsAppQueueService.queueBulkWhatsApp).not.toHaveBeenCalled();
      expect(repository.recordSendStatus).not.toHaveBeenCalled();
    });

    it('refuses a SYSTEM_LINKS that was already triggered for the same scope', async () => {
      const { service, repository, emailQueueService } = buildService();
      repository.hasTriggeredScopedBulkSend.mockResolvedValue(true);

      await expect(service.sendSystemLinksBulk(5, 1)).rejects.toBeInstanceOf(
        InifniConflictException,
      );

      expect(repository.hasTriggeredScopedBulkSend).toHaveBeenCalledWith(
        5,
        null,
        SessionCommunicationPurposeEnum.SYSTEM_LINKS,
      );
      expect(repository.getSystemPlaceholderLinks).not.toHaveBeenCalled();
      expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    });

    it('checks the per-session scope when a sessionId is given, so a program-level run does not block it', async () => {
      const { service, repository, emailQueueService } = buildService();
      repository.hasTriggeredScopedBulkSend.mockResolvedValue(false);

      await service.sendCommonInviteBulk(5, 1, 12);

      // Asked about session 12 specifically — not about the program-level row.
      expect(repository.hasTriggeredScopedBulkSend).toHaveBeenCalledWith(
        5,
        12,
        SessionCommunicationPurposeEnum.COMMON_INVITE,
      );
      expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    });

    it('allows a retry after a SKIPPED run — the guard only counts TRIGGERED', async () => {
      const { service, repository, emailQueueService } = buildService();
      // hasTriggeredScopedBulkSend filters to TRIGGERED in the repository, so a SKIPPED-only
      // history answers false and the send proceeds.
      repository.hasTriggeredScopedBulkSend.mockResolvedValue(false);

      await service.sendCommonInviteBulk(5, 1);

      expect(emailQueueService.queueBulkEmail).toHaveBeenCalledTimes(1);
    });
  });

  it('COMMON_INVITE without a sessionId uses the program-level templates and passes no sessionId in merge context', async () => {
    const { service, mergeDataService } = buildService();

    await service.sendCommonInviteBulk(5, 1);

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.COMMON_INVITE_EMAIL_SEEKER);
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.COMMON_INVITE_WATI_SEEKER);
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.COMMON_INVITE_PER_SESSION_EMAIL_SEEKER,
    );
    // extraMergeContext (6th arg) carries no sessionId for a program-level send.
    for (const call of mergeDataService.getTemplateWithMergeInfo.mock.calls) {
      expect(call[5]?.sessionId).toBeUndefined();
    }
  });

  it('COMMON_INVITE with a sessionId uses the per-session templates and threads sessionId into merge context', async () => {
    const { service, mergeDataService, repository } = buildService();

    await service.sendCommonInviteBulk(5, 1, 12);

    // Recipients are narrowed to the target session.
    expect(repository.getCommonInviteRecipients).toHaveBeenCalledWith(5, 12);

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.COMMON_INVITE_PER_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.COMMON_INVITE_PER_SESSION_WATI_SEEKER,
    );
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.COMMON_INVITE_EMAIL_SEEKER,
    );
    // Every resolve carries the sessionId so session_name/date/time resolve that session.
    for (const call of mergeDataService.getTemplateWithMergeInfo.mock.calls) {
      expect(call[5]?.sessionId).toBe(12);
    }
  });

  it('SYSTEM_LINKS without a sessionId uses the program-level template', async () => {
    const { service, mergeDataService } = buildService();

    await service.sendSystemLinksBulk(5, 1);

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.SYSTEM_LINKS_EMAIL_ADMIN);
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.SYSTEM_LINKS_PER_SESSION_EMAIL_ADMIN,
    );
    for (const call of mergeDataService.getTemplateWithMergeInfo.mock.calls) {
      expect(call[5]?.sessionId).toBeUndefined();
    }
  });

  it('SYSTEM_LINKS with a sessionId uses the per-session template, narrows the table, and threads sessionId', async () => {
    const { service, mergeDataService, repository } = buildService();

    await service.sendSystemLinksBulk(5, 1, 12);

    // The system-links table is narrowed to the target session.
    expect(repository.getSystemPlaceholderLinks).toHaveBeenCalledWith(5, 12);

    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.SYSTEM_LINKS_PER_SESSION_EMAIL_ADMIN,
    );
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.SYSTEM_LINKS_EMAIL_ADMIN,
    );
    for (const call of mergeDataService.getTemplateWithMergeInfo.mock.calls) {
      expect(call[5]?.sessionId).toBe(12);
    }
  });

  // The links ARE the System Links email — there is no other content — so sending with an empty
  // table would tell admins the links are missing rather than that they were never generated.
  it('SYSTEM_LINKS refuses to send when no system links exist for the program', async () => {
    const { service, repository, emailQueueService, communicationService } = buildService();
    repository.getSystemPlaceholderLinks.mockResolvedValue([]);

    await expect(service.sendSystemLinksBulk(5, 1)).rejects.toBeInstanceOf(
      InifniBadRequestException,
    );

    expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
    expect(communicationService.sendBulkEmail).not.toHaveBeenCalled();
    // Nothing went out, so nothing is recorded as having been triggered either.
    expect(repository.recordSendStatus).not.toHaveBeenCalled();
  });

  it('SYSTEM_LINKS refuses when rows exist but none carries a join url', async () => {
    const { service, repository, emailQueueService } = buildService();
    // buildSystemJoiningDetailsTable filters on joinUrl, so these rows produce an empty table.
    repository.getSystemPlaceholderLinks.mockResolvedValue([
      { displayName: 'Staff 1', joinUrl: null },
      { displayName: 'Staff 2', joinUrl: null },
    ]);

    await expect(service.sendSystemLinksBulk(5, 1, 12)).rejects.toBeInstanceOf(
      InifniBadRequestException,
    );
    expect(emailQueueService.queueBulkEmail).not.toHaveBeenCalled();
  });

  it('GENERAL_LINK_WELCOME bulk (program-level) reuses the seeker WELCOME templates, not a separate template set', async () => {
    const { service, mergeDataService, repository } = buildService();

    await service.sendGeneralLinkBulk(
      { programId: 5, purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME } as any,
      1,
    );

    expect(repository.getGeneralLinkRecipients).toHaveBeenCalledWith(
      5,
      null,
      SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME,
    );
    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.WELCOME_EMAIL_SEEKER);
    expect(accessKeysUsed).toContain(CommunicationTemplateAccessKeyEnum.WELCOME_WATI_SEEKER);
    // resolveAllFieldsAsCommon + common_* merge context, same mechanism as Common Invite/System
    // Links — no registrationId for a general-link recipient.
    const optionsUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls[0][6];
    expect(optionsUsed).toEqual({ resolveAllFieldsAsCommon: true });
    const extraContextUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls[0][5];
    expect(extraContextUsed).toMatchObject({
      common_user_name: 'General Link Recipient',
      common_zoom_join_link: 'https://zoom.us/j/777',
      common_meeting_id: '777',
      common_meeting_passcode: 'genpass',
    });
  });

  it('GENERAL_LINK_INVITE bulk at the program\'s last session resolves FINAL, reuses the seeker final-invite template, and is NOT gated by prior-session attendance', async () => {
    const { service, sessionRepo, mergeDataService, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    // Three sessions; target (id 12) is the last -> FINAL.
    sessionRepo.find.mockResolvedValue([{ id: 10 }, { id: 11 }, { id: 12 }]);

    await service.sendGeneralLinkBulk(
      {
        programId: 5,
        sessionId: 12,
        purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE,
      } as any,
      1,
    );

    // No isFinalSession/attendance gate threaded through — these recipients are staff/admin, not
    // seekers on a structured journey, so occurrence only selects the template.
    expect(repository.getGeneralLinkRecipients).toHaveBeenCalledWith(
      5,
      12,
      SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE,
    );
    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_FINAL_SESSION_EMAIL_SEEKER,
    );
    expect(accessKeysUsed).not.toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_REGULAR_SESSION_EMAIL_SEEKER,
    );
    // Occurrence is now recorded on the status row too, matching a real seeker Invite send.
    expect(repository.recordSendStatus).toHaveBeenCalledWith(
      expect.objectContaining({ purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE, occurrence: SessionOccurrence.FINAL }),
    );
  });

  it('GENERAL_LINK_ABSENT bulk for a regular (non-final) session reuses the seeker regular-absent template', async () => {
    const { service, sessionRepo, mergeDataService, repository } = buildService();
    sessionRepo.findOne.mockResolvedValue(onlineSession);
    sessionRepo.find.mockResolvedValue([{ id: 12 }]); // single session -> REGULAR

    await service.sendGeneralLinkBulk(
      {
        programId: 5,
        sessionId: 12,
        purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_ABSENT,
      } as any,
      1,
    );

    expect(repository.getGeneralLinkRecipients).toHaveBeenCalledWith(
      5,
      12,
      SessionCommunicationPurposeEnum.GENERAL_LINK_ABSENT,
    );
    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.ABSENT_REGULAR_SESSION_EMAIL_SEEKER,
    );
  });

  it('rejects a general-link bulk send with no eligible recipients', async () => {
    const { service, repository } = buildService();
    repository.getGeneralLinkRecipients.mockResolvedValue([]);

    await expect(
      service.sendGeneralLinkBulk(
        { programId: 5, purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME } as any,
        1,
      ),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
  });

  it('GENERAL_LINK_INVITE single send at occurrence FINAL reuses the seeker final-invite template, drops the applyValidation gate, and is not attendance-gated', async () => {
    const { service, mergeDataService, repository } = buildService();

    await service.sendGeneralLinkSingle(
      {
        programId: 5,
        generatedLinkId: 4210,
        purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE,
        occurrence: SessionOccurrence.FINAL,
      } as any,
      1,
    );

    // No applyValidation argument any more — the repository always applies eligibility.
    // programId is threaded through so the repository can guard the row against cross-program use.
    // occurrence is NOT passed to the repository — FINAL only picks the template above, it never
    // gates the recipient (these are staff/admin, not seekers on a structured journey).
    expect(repository.getGeneralLinkRecipientById).toHaveBeenCalledWith(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE,
    );
    const accessKeysUsed = mergeDataService.getTemplateWithMergeInfo.mock.calls.map(
      (call: any[]) => call[1],
    );
    expect(accessKeysUsed).toContain(
      CommunicationTemplateAccessKeyEnum.INVITE_FINAL_SESSION_EMAIL_SEEKER,
    );
  });

  it('single send threads the recipient\'s OWN program_session_id into the merge context, not the program\'s first session', async () => {
    const { service, mergeDataService, repository } = buildService();
    // This row belongs to session 999 — a different session than any bulk-send fixture uses.
    repository.getGeneralLinkRecipientById.mockResolvedValue({
      userId: null,
      emailAddress: 'general@example.com',
      mobileNumber: '919999999998',
      displayName: 'General Link Recipient',
      joinUrl: 'https://zoom.us/j/999',
      meetingId: '999',
      meetingPasscode: 'genpass',
      programSessionId: 999,
    });

    await service.sendGeneralLinkSingle(
      {
        programId: 5,
        generatedLinkId: 4210,
        purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE,
        occurrence: SessionOccurrence.FINAL,
      } as any,
      1,
    );

    for (const call of mergeDataService.getTemplateWithMergeInfo.mock.calls) {
      expect(call[5]?.sessionId).toBe(999);
    }
  });

  it('rejects a general-link single send when the recipient is not applicable (not found/ineligible)', async () => {
    const { service, repository } = buildService();
    repository.getGeneralLinkRecipientById.mockResolvedValue(null);

    await expect(
      service.sendGeneralLinkSingle(
        {
          programId: 5,
          generatedLinkId: 4210,
          purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_ABSENT,
          occurrence: SessionOccurrence.REGULAR,
        } as any,
        1,
      ),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
  });

  it('getSummary reshapes grouped rows into a per-registration, per-communication structure', async () => {
    const { service, repository } = buildService();
    repository.getSummary.mockResolvedValue([
      {
        registrationId: 101,
        purpose: SessionCommunicationPurposeEnum.ABSENT,
        occurrence: SessionOccurrence.FINAL,
        channel: CommunicationTypeEnum.EMAIL,
        count: 2,
        lastSentAt: '2026-07-06T10:00:00Z',
      },
      {
        registrationId: 101,
        purpose: SessionCommunicationPurposeEnum.ABSENT,
        occurrence: SessionOccurrence.FINAL,
        channel: CommunicationTypeEnum.WHATSAPP,
        count: 1,
        lastSentAt: '2026-07-06T11:00:00Z',
      },
      {
        registrationId: 101,
        purpose: SessionCommunicationPurposeEnum.VALUE_CARD,
        occurrence: null,
        channel: CommunicationTypeEnum.EMAIL,
        count: 1,
        lastSentAt: '2026-07-06T12:00:00Z',
      },
    ]);

    const summary = await service.getSummary(5);

    expect(summary).toEqual([
      {
        registrationId: 101,
        byCommunication: {
          ABSENT_FINAL: { email: 2, whatsapp: 1, lastSentAt: '2026-07-06T11:00:00Z' },
          VALUE_CARD: { email: 1, whatsapp: 0, lastSentAt: '2026-07-06T12:00:00Z' },
        },
      },
    ]);
  });

  it('getTriggeredPurposesForSessions delegates straight to the repository', async () => {
    const { service, repository } = buildService();
    const result = new Map([[12, [SessionCommunicationPurposeEnum.INVITE]]]);
    repository.findTriggeredPurposesBySessions.mockResolvedValue(result);

    const purposes = await service.getTriggeredPurposesForSessions(5, [12, 13]);

    expect(repository.findTriggeredPurposesBySessions).toHaveBeenCalledWith(5, [12, 13]);
    expect(purposes).toBe(result);
  });
});
