From 572168f2784bc5b5501f7562d60a609ea2168e59 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> Date: Thu, 28 May 2026 12:04:31 -0500 Subject: [PATCH 1/2] feat: automate lottery audit workflow --- api/.env.template | 2 + api/src/modules/lottery.module.ts | 2 + api/src/services/lottery.service.ts | 51 ++++++ .../unit/services/lottery.service.spec.ts | 166 +++++++++++++++++- 4 files changed, 218 insertions(+), 3 deletions(-) diff --git a/api/.env.template b/api/.env.template index dea959182c0..5a4c77a4ee1 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 9990bc868d6..d172fee3e9b 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 34015fd4269..c549a180ea2 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 098720a7ded..8b9815ab21b 100644 --- a/api/test/unit/services/lottery.service.spec.ts +++ b/api/test/unit/services/lottery.service.spec.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'crypto'; import { Request as ExpressRequest, Response } from 'express'; -import { HttpModule } from '@nestjs/axios'; import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SchedulerRegistry } from '@nestjs/schedule'; +import { of, throwError } from 'rxjs'; import { Test, TestingModule } from '@nestjs/testing'; import { ListingEventsTypeEnum, @@ -13,6 +13,7 @@ import { ReviewOrderTypeEnum, } from '@prisma/client'; import { S3Service } from '../../../src/services/s3.service'; +import { HttpService } from '@nestjs/axios'; import { mockApplicationSet } from './application.service.spec'; import { mockMultiselectQuestion } from './multiselect-question.service.spec'; import { randomNoun } from '../../../prisma/seed-helpers/word-generator'; @@ -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'); + }); + }); }); From 2154fe6f60a8601d840de43e6265c33aedeb53aa Mon Sep 17 00:00:00 2001 From: Morgan Ludtke Date: Wed, 15 Jul 2026 16:12:12 -0500 Subject: [PATCH 2/2] fix: organize imports --- api/test/unit/services/lottery.service.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/test/unit/services/lottery.service.spec.ts b/api/test/unit/services/lottery.service.spec.ts index 8b9815ab21b..0aa647c7a66 100644 --- a/api/test/unit/services/lottery.service.spec.ts +++ b/api/test/unit/services/lottery.service.spec.ts @@ -1,9 +1,7 @@ -import { randomUUID } from 'crypto'; -import { Request as ExpressRequest, Response } from 'express'; +import { HttpService } from '@nestjs/axios'; import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { of, throwError } from 'rxjs'; import { Test, TestingModule } from '@nestjs/testing'; import { ListingEventsTypeEnum, @@ -12,10 +10,9 @@ import { MultiselectQuestionsApplicationSectionEnum, ReviewOrderTypeEnum, } from '@prisma/client'; -import { S3Service } from '../../../src/services/s3.service'; -import { HttpService } from '@nestjs/axios'; -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'; @@ -34,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();