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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/src/modules/lottery.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +15,7 @@ import { SnapshotCreateModule } from './snapshot-create.module';
@Module({
imports: [
ApplicationExporterModule,
HttpModule,
PrismaModule,
ListingModule,
EmailModule,
Expand Down
51 changes: 51 additions & 0 deletions api/src/services/lottery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +71,7 @@ export class LotteryService {
private permissionService: PermissionService,
private cronJobService: CronJobService,
private snapshotCreateService: SnapshotCreateService,
private httpService: HttpService,
) {}

onModuleInit() {
Expand Down Expand Up @@ -185,6 +188,20 @@ export class LotteryService {
},
user,
);

// Trigger automated audit if the lottery audit endpoint is configured
const auditEndpoint = this.configService.get<string>(
'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(
Expand Down Expand Up @@ -903,4 +920,38 @@ export class LotteryService {

return results;
}

async triggerLotteryAudit(
listingId: string,
endpoint: string,
): Promise<void> {
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,
);
}
}
}
176 changes: 168 additions & 8 deletions api/test/unit/services/lottery.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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>(LotteryService);
Expand Down Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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');
});
});
});
Loading