diff --git a/api/.env.template b/api/.env.template index dea959182c..5a4c77a4ee 100644 --- a/api/.env.template +++ b/api/.env.template @@ -16,6 +16,8 @@ CLOUDINARY_CLOUD_NAME=exygy APP_SECRET="some-long-secret-key" # url for the proxy PROXY_URL= +# This endpoint is called after a successful lottery run. If null, the audit operation is skipped. +LOTTERY_AUDIT_ENDPOINT= # the node env the app should be running as NODE_ENV=development # how long a generated multi-factor authentication code should be diff --git a/api/src/modules/lottery.module.ts b/api/src/modules/lottery.module.ts index 9990bc868d..d172fee3e9 100644 --- a/api/src/modules/lottery.module.ts +++ b/api/src/modules/lottery.module.ts @@ -1,4 +1,5 @@ import { Logger, Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { ApplicationExporterModule } from './application-exporter.module'; import { ConfigService } from '@nestjs/config'; import { EmailModule } from './email.module'; @@ -14,6 +15,7 @@ import { SnapshotCreateModule } from './snapshot-create.module'; @Module({ imports: [ ApplicationExporterModule, + HttpModule, PrismaModule, ListingModule, EmailModule, diff --git a/api/src/services/lottery.service.ts b/api/src/services/lottery.service.ts index 34015fd426..c549a180ea 100644 --- a/api/src/services/lottery.service.ts +++ b/api/src/services/lottery.service.ts @@ -9,6 +9,8 @@ import { Logger, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; import { ApplicationSubmissionTypeEnum, LanguagesEnum, @@ -69,6 +71,7 @@ export class LotteryService { private permissionService: PermissionService, private cronJobService: CronJobService, private snapshotCreateService: SnapshotCreateService, + private httpService: HttpService, ) {} onModuleInit() { @@ -185,6 +188,20 @@ export class LotteryService { }, user, ); + + // Trigger automated audit if the lottery audit endpoint is configured + const auditEndpoint = this.configService.get( + 'LOTTERY_AUDIT_ENDPOINT', + ); + if (auditEndpoint) { + this.triggerLotteryAudit(listingId, auditEndpoint).catch( + (auditError) => { + this.logger.error( + `Automated lottery audit failed to trigger for listing ${listingId}: ${auditError.message}`, + ); + }, + ); + } } catch (e) { console.error(e); await this.lotteryStatus( @@ -903,4 +920,38 @@ export class LotteryService { return results; } + + async triggerLotteryAudit( + listingId: string, + endpoint: string, + ): Promise { + this.logger.warn(`Triggering lottery audit for listing ${listingId}`); + try { + await firstValueFrom( + this.httpService.post( + endpoint, + { ListingId: listingId }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ); + } catch (error) { + this.logger.error( + `Failed to trigger lottery audit for listing ${listingId}: ${error?.message}`, + ); + if (error?.response) { + throw new HttpException( + error.response?.data || 'Failed to start lottery audit', + error.response?.status || 500, + ); + } + throw new HttpException( + error?.message || 'Failed to start lottery audit', + 500, + ); + } + } } diff --git a/api/test/unit/services/lottery.service.spec.ts b/api/test/unit/services/lottery.service.spec.ts index 098720a7de..0aa647c7a6 100644 --- a/api/test/unit/services/lottery.service.spec.ts +++ b/api/test/unit/services/lottery.service.spec.ts @@ -1,6 +1,4 @@ -import { randomUUID } from 'crypto'; -import { Request as ExpressRequest, Response } from 'express'; -import { HttpModule } from '@nestjs/axios'; +import { HttpService } from '@nestjs/axios'; import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SchedulerRegistry } from '@nestjs/schedule'; @@ -12,9 +10,9 @@ import { MultiselectQuestionsApplicationSectionEnum, ReviewOrderTypeEnum, } from '@prisma/client'; -import { S3Service } from '../../../src/services/s3.service'; -import { mockApplicationSet } from './application.service.spec'; -import { mockMultiselectQuestion } from './multiselect-question.service.spec'; +import { randomUUID } from 'crypto'; +import { Request as ExpressRequest, Response } from 'express'; +import { of, throwError } from 'rxjs'; import { randomNoun } from '../../../prisma/seed-helpers/word-generator'; import { Application } from '../../../src/dtos/applications/application.dto'; import { ListingLotteryStatus } from '../../../src/dtos/listings/listing-lottery-status.dto'; @@ -33,8 +31,11 @@ import { LotteryService } from '../../../src/services/lottery.service'; import { MultiselectQuestionService } from '../../../src/services/multiselect-question.service'; import { PermissionService } from '../../../src/services/permission.service'; import { PrismaService } from '../../../src/services/prisma.service'; +import { S3Service } from '../../../src/services/s3.service'; import { SnapshotCreateService } from '../../../src/services/snapshot-create.service'; import { TranslationService } from '../../../src/services/translation.service'; +import { mockApplicationSet } from './application.service.spec'; +import { mockMultiselectQuestion } from './multiselect-question.service.spec'; const canOrThrowMock = jest.fn(); const lotteryReleasedMock = jest.fn(); @@ -80,15 +81,20 @@ describe('Testing lottery service', () => { lotteryPublishedApplicant: lotteryPublishedApplicantMock, }, }, - ConfigService, + { + provide: HttpService, + useValue: { + post: jest.fn(), + get: jest.fn(), + }, + }, Logger, SchedulerRegistry, GoogleTranslateService, CronJobService, SnapshotCreateService, ], - imports: [HttpModule], }).compile(); service = module.get(LotteryService); @@ -815,6 +821,98 @@ describe('Testing lottery service', () => { expect(prisma.listingSnapshot.create).toHaveBeenCalled(); }); + + it('should trigger lottery audit if env variable is set', async () => { + const mockEndpoint = 'https://example.com/audit'; + config.get = jest.fn().mockImplementation((key: string) => { + if (key === 'LOTTERY_AUDIT_ENDPOINT') return mockEndpoint; + return undefined; // or the real value for other keys + }); + const mockWorkQueueItemId = randomUUID(); + + const httpService = (service as any).httpService; + jest.spyOn(httpService, 'post').mockReturnValue( + of({ + data: { WorkQueueItemID: mockWorkQueueItemId }, + }), + ); + const listingId = randomUUID(); + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + userRoles: { isAdmin: true }, + } as unknown as User; + + canOrThrowMock.mockResolvedValue(true); + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: listingId, + jurisdictions: { + featureFlags: [], + }, + lotteryLastRunAt: null, + lotteryStatus: null, + status: ListingsStatusEnum.closed, + }); + const applications = mockApplicationSet(5, new Date()); + prisma.applications.findMany = jest.fn().mockReturnValue(applications); + + prisma.multiselectQuestions.findMany = jest.fn().mockReturnValue([ + { + ...mockMultiselectQuestion( + 0, + new Date(), + MultiselectQuestionsApplicationSectionEnum.preferences, + ), + options: [ + { id: 1, text: 'text' }, + { id: 2, text: 'text', collectAddress: true }, + ], + }, + { + ...mockMultiselectQuestion( + 1, + new Date(), + MultiselectQuestionsApplicationSectionEnum.programs, + ), + options: [{ id: 1, text: 'text' }], + }, + ]); + + prisma.applicationLotteryTotal.create = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + prisma.applicationLotteryPositions.createMany = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + prisma.applicationLotteryTotal.createMany = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + prisma.listings.update = jest.fn().mockResolvedValue({ + id: listingId, + lotteryLastRunAt: null, + lotteryStatus: null, + }); + prisma.userAccounts.findMany = jest.fn().mockResolvedValue([]); + prisma.listingSnapshot.create = jest + .fn() + .mockResolvedValue({ id: 'example snapshot id' }); + + await service.lotteryGenerate( + { user: requestingUser } as unknown as ExpressRequest, + {} as unknown as Response, + { id: listingId }, + ); + expect(httpService.post).toHaveBeenCalledWith( + mockEndpoint, + { ListingId: listingId }, + { headers: { 'Content-Type': 'application/json' } }, + ); + }); }); describe('Testing lotteryStatus()', () => { @@ -1624,4 +1722,66 @@ describe('Testing lottery service', () => { ).rejects.toThrowError(); }); }); + + describe('Testing triggerLotteryAudit()', () => { + it('should trigger a lottery audit and return the response', async () => { + const mockListingId = randomUUID(); + const mockEndpoint = 'https://example.com/audit'; + const mockWorkQueueItemId = randomUUID(); + + const httpService = (service as any).httpService; + jest.spyOn(httpService, 'post').mockReturnValue( + of({ + data: { WorkQueueItemID: mockWorkQueueItemId }, + }), + ); + + await service.triggerLotteryAudit(mockListingId, mockEndpoint); + expect(httpService.post).toHaveBeenCalledWith( + mockEndpoint, + { ListingId: mockListingId }, + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + + it('should throw HttpException with 500 when HttpService returns error response', async () => { + const mockListingId = randomUUID(); + const mockEndpoint = 'https://example.com/audit'; + + const httpService = (service as any).httpService; + jest.spyOn(httpService, 'post').mockReturnValue( + throwError(() => ({ + response: { + status: 500, + data: 'Internal error from lottery audit endpoint', + }, + })), + ); + + await expect( + async () => + await service.triggerLotteryAudit(mockListingId, mockEndpoint), + ).rejects.toThrowError('Internal error from lottery audit endpoint'); + }); + + it('should throw HttpException with 409 when HttpService returns 409', async () => { + const mockListingId = randomUUID(); + const mockEndpoint = 'https://example.com/audit'; + + const httpService = (service as any).httpService; + jest.spyOn(httpService, 'post').mockReturnValue( + throwError(() => ({ + response: { + status: 409, + data: 'Conflict error from lottery audit endpoint', + }, + })), + ); + + await expect( + async () => + await service.triggerLotteryAudit(mockListingId, mockEndpoint), + ).rejects.toThrowError('Conflict error from lottery audit endpoint'); + }); + }); });