From 0c33324511e81bc040d5723d3825d60c9561e0de Mon Sep 17 00:00:00 2001 From: rmagrys Date: Wed, 24 Sep 2025 16:44:30 +0200 Subject: [PATCH 1/5] repaired search and added filters by status --- .../http/controllers/refresh.controller.ts | 23 ++-- .../middleware/validate-request.middleware.ts | 8 +- .../http/validators/github-issue.validator.ts | 15 ++- .../application/dtos/GetRefreshesQueryDto.ts | 8 ++ .../get-refreshes/get-refreshes.query.ts | 11 +- .../src/constants/validation-messages.ts | 15 +++ .../refresh/get--all-refreshes.e2e.test.ts | 122 +++++++++++++++--- .../repositories/issue-details.repository.ts | 82 +++++++----- 8 files changed, 218 insertions(+), 66 deletions(-) create mode 100644 packages/application/src/application/dtos/GetRefreshesQueryDto.ts diff --git a/packages/application/src/api/http/controllers/refresh.controller.ts b/packages/application/src/api/http/controllers/refresh.controller.ts index 506fb04..83aec17 100644 --- a/packages/application/src/api/http/controllers/refresh.controller.ts +++ b/packages/application/src/api/http/controllers/refresh.controller.ts @@ -7,6 +7,8 @@ import { httpGet, httpPost, httpPut, + PARAMETER_TYPE, + params, request, requestBody, requestParam, @@ -30,6 +32,7 @@ import { SignatureType } from '@src/patterns/decorators/signature-guard.decorato import { SignatureGuard } from '@src/patterns/decorators/signature-guard.decorator'; import { RejectRefreshCommand } from '@src/application/use-cases/refresh-issues/reject-refesh.command'; import { ApproveRefreshCommand } from '@src/application/use-cases/refresh-issues/approve-refresh.command'; +import { GetRefreshesQueryDto } from '@src/application/dtos/GetRefreshesQueryDto'; const RES = RESPONSE_MESSAGES.REFRESH_CONTROLLER; @@ -42,18 +45,16 @@ export class RefreshController { @inject(TYPES.RoleService) private readonly _roleService: RoleService, ) {} - @httpGet('', ...validateRefreshesQuery) - async getAllRefreshes(@request() req: Request, @response() res: Response) { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json(badRequest(RES.INVALID_QUERY, errors.array())); - } - - const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 20; - const search = req.query.search as string | undefined; + @httpGet('', validateRequest(validateRefreshesQuery, RES.INVALID_QUERY)) + async getAllRefreshes( + @params(PARAMETER_TYPE.QUERY) getRefreshesQueryDto: GetRefreshesQueryDto, + @response() res: Response, + ) { + const { page, limit, search, status } = getRefreshesQueryDto; - const result = await this._queryBus.execute(new GetRefreshesQuery(page, limit, search)); + const result = await this._queryBus.execute( + new GetRefreshesQuery(parseInt(page), parseInt(limit), search, status), + ); return res.json(ok(RES.GET_ALL, result)); } diff --git a/packages/application/src/api/http/middleware/validate-request.middleware.ts b/packages/application/src/api/http/middleware/validate-request.middleware.ts index f3b21d5..3b4c97e 100644 --- a/packages/application/src/api/http/middleware/validate-request.middleware.ts +++ b/packages/application/src/api/http/middleware/validate-request.middleware.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from 'express'; -import { validationResult, ValidationChain } from 'express-validator'; +import { validationResult, ValidationChain, ValidationError } from 'express-validator'; import { badRequest } from '@src/api/http/processors/response'; -export function validateRequest(validators: ValidationChain[]) { +export function validateRequest(validators: ValidationChain[], responseMessage?: string) { return async (req: Request, res: Response, next: NextFunction) => { await Promise.all(validators.map(validator => validator.run(req))); @@ -10,7 +10,9 @@ export function validateRequest(validators: ValidationChain[]) { if (!errors.isEmpty()) { const errorMessages = errors.array().map(error => error.msg); - return res.status(400).json(badRequest('Validation failed', errorMessages)); + return res + .status(400) + .json(badRequest(responseMessage ?? 'Validation failed', errorMessages)); } next(); diff --git a/packages/application/src/api/http/validators/github-issue.validator.ts b/packages/application/src/api/http/validators/github-issue.validator.ts index 43a4d1c..799589a 100644 --- a/packages/application/src/api/http/validators/github-issue.validator.ts +++ b/packages/application/src/api/http/validators/github-issue.validator.ts @@ -1,5 +1,6 @@ import { body, query } from 'express-validator'; import { VALIDATION_MESSAGES } from '@src/constants/validation-messages'; +import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; export const validateIssueUpsert = [ body('issue') @@ -45,7 +46,15 @@ export const validateIssueUpsert = [ ]; export const validateRefreshesQuery = [ - query('page').optional().isInt({ min: 1 }), - query('limit').optional().isInt({ min: 1, max: 100 }), - query('search').optional().isString(), + query('page').optional().isInt({ min: 1 }).withMessage(VALIDATION_MESSAGES.QUERY.PAGE.INVALID), + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage(VALIDATION_MESSAGES.QUERY.LIMIT.INVALID), + query('search').optional().isString().withMessage(VALIDATION_MESSAGES.QUERY.SEARCH.INVALID), + query('status').optional().isArray().withMessage(VALIDATION_MESSAGES.QUERY.STATUS.INVALID), + query('status.*') + .optional() + .isIn(Object.values(RefreshStatus)) + .withMessage(VALIDATION_MESSAGES.QUERY.STATUS.INVALID_VALUE), ]; diff --git a/packages/application/src/application/dtos/GetRefreshesQueryDto.ts b/packages/application/src/application/dtos/GetRefreshesQueryDto.ts new file mode 100644 index 0000000..b9a1d93 --- /dev/null +++ b/packages/application/src/application/dtos/GetRefreshesQueryDto.ts @@ -0,0 +1,8 @@ +import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; + +export interface GetRefreshesQueryDto { + page: string; + limit: string; + search?: string; + status?: RefreshStatus[]; +} diff --git a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts index 3a72bcc..7f0c3e4 100644 --- a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts +++ b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts @@ -3,12 +3,14 @@ import { inject, injectable } from 'inversify'; import { TYPES } from '@src/types'; import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; +import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; export class GetRefreshesQuery implements IQuery { constructor( public readonly page: number = 1, public readonly limit: number = 10, public readonly search?: string, + public readonly status?: RefreshStatus[], ) {} } @@ -21,6 +23,13 @@ export class GetRefreshesQueryHandler implements IQueryHandler { .withEventBus() .withCommandBus() .withQueryBus() - .withMappers() + .withGithubClient({} as unknown as IGithubClient) + .withConfig(TYPES.AllocatorGovernanceConfig, { owner: 'owner', repo: 'repo' }) + .withConfig(TYPES.AllocatorRegistryConfig, { owner: 'owner', repo: 'repo' }) + .withConfig(TYPES.GovernanceConfig, { addresses: ['0x123'] }) + .withResolvers() .withServices() - .withGithubClient() + .withPublishers() + .withMappers() .withRepositories() .withCommandHandlers() .withQueryHandlers() @@ -118,7 +126,7 @@ describe('GET /api/v1/refreshes', () => { it('should return filtered refreshes when search parameter is provided', async () => { const searchTerm = 'testing search term'; const issue = DatabaseRefreshFactory.create({ - title: `[DataCap Refresh] - ${searchTerm}`, + title: `${searchTerm}`, }); const otherIssues = Array.from({ length: 3 }, () => DatabaseRefreshFactory.create()); await db.collection('issueDetails').insertMany([issue, ...otherIssues]); @@ -128,6 +136,9 @@ describe('GET /api/v1/refreshes', () => { .query({ search: searchTerm }) .expect(200); + expect(response.body.data.results).toHaveLength(1); + expect(response.body.data.results[0].title).toContain(searchTerm); + expect(response.body).toStrictEqual({ status: '200', message: 'Retrieved refreshes', @@ -141,6 +152,68 @@ describe('GET /api/v1/refreshes', () => { results: response.body.data.results.map((item: any) => ({ _id: item._id, githubIssueId: item.githubIssueId, + githubIssueNumber: item.githubIssueNumber, + actorId: item.actorId, + maAddress: item.maAddress, + msigAddress: item.msigAddress, + refreshStatus: item.refreshStatus, + metapathwayType: item.metapathwayType, + dataCap: item.dataCap, + title: item.title, + creator: { + name: item.creator.name, + userId: item.creator.userId, + }, + assignees: item.assignees, + labels: item.labels, + state: item.state, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + closedAt: item.closedAt, + jsonNumber: item.jsonNumber, + })), + }, + }); + }); + + it('should filter refreshes when status parameter is provided', async () => { + const status = RefreshStatus.PENDING; + const pendingIssue = DatabaseRefreshFactory.create({ + refreshStatus: status, + }); + const otherIssue = DatabaseRefreshFactory.create({ + refreshStatus: RefreshStatus.APPROVED, + }); + await db.collection('issueDetails').insertMany([pendingIssue, otherIssue]); + + const response = await request(app) + .get('/api/v1/refreshes') + .query({ 'status[]': [status] }) + .expect(200); + + expect(response.body.data.results).toHaveLength(1); + expect(response.body.data.results[0].refreshStatus).toBe(status); + + expect(response.body).toStrictEqual({ + status: '200', + message: 'Retrieved refreshes', + data: { + pagination: { + currentPage: response.body.data.pagination.currentPage, + itemsPerPage: response.body.data.pagination.itemsPerPage, + totalItems: response.body.data.pagination.totalItems, + totalPages: response.body.data.pagination.totalPages, + }, + results: response.body.data.results.map((item: any) => ({ + _id: item._id, + githubIssueId: item.githubIssueId, + githubIssueNumber: item.githubIssueNumber, + actorId: item.actorId, + maAddress: item.maAddress, + msigAddress: item.msigAddress, + refreshStatus: item.refreshStatus, + metapathwayType: item.metapathwayType, + dataCap: item.dataCap, title: item.title, creator: { name: item.creator.name, @@ -169,22 +242,33 @@ describe('GET /api/v1/refreshes', () => { .expect(400); expect(response.body).toStrictEqual({ - errors: [ - { - location: 'query', - msg: 'Invalid value', - path: 'page', - type: 'field', - value: '-1', - }, - { - location: 'query', - msg: 'Invalid value', - path: 'limit', - type: 'field', - value: 'invalid', - }, - ], + errors: ['Query page must be a positive integer', 'Query limit must be a positive integer'], + status: '400', + message: 'Invalid query parameters', + }); + }); + + it('should return 400 when invalid status parameter is provided', async () => { + const response = await request(app) + .get('/api/v1/refreshes') + .query({ status: 'invalid' }) + .expect(400); + + expect(response.body).toStrictEqual({ + errors: ['Query status must be an array'], + status: '400', + message: 'Invalid query parameters', + }); + }); + + it('should return 400 when invalid status item is provided', async () => { + const response = await request(app) + .get('/api/v1/refreshes') + .query({ 'status[]': ['invalid'] }) + .expect(400); + + expect(response.body).toStrictEqual({ + errors: ['Query status item must be a valid status'], status: '400', message: 'Invalid query parameters', }); diff --git a/packages/application/src/infrastructure/repositories/issue-details.repository.ts b/packages/application/src/infrastructure/repositories/issue-details.repository.ts index 850194e..83bf048 100644 --- a/packages/application/src/infrastructure/repositories/issue-details.repository.ts +++ b/packages/application/src/infrastructure/repositories/issue-details.repository.ts @@ -2,7 +2,7 @@ import { IRepository } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; import { BulkWriteResult, Db, Filter, FindOptions, UpdateFilter, WithId } from 'mongodb'; import { TYPES } from '@src/types'; -import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; +import { IssueDetails, RefreshStatus } from '@src/infrastructure/repositories/issue-details'; type PaginatedResults = { results: T[]; @@ -14,14 +14,24 @@ type PaginatedResults = { }; }; +type GetPaginatedQuery = { + page: number; + limit: number; + search?: string; + filters?: { + refreshStatus?: RefreshStatus[]; + }; +}; + export interface IIssueDetailsRepository extends IRepository { update(issueDetails: Partial): Promise; - getPaginated( - page: number, - limit: number, - search?: string, - ): Promise>; + getPaginated({ + page, + limit, + search, + filters, + }: GetPaginatedQuery): Promise>; getAll(): Promise; @@ -112,39 +122,53 @@ class IssueDetailsRepository implements IIssueDetailsRepository { ); } - async getPaginated( - page: number, - limit: number, - search?: string, - ): Promise> { - const skip = (page - 1) * limit; - const filter: any = {}; - const orConditions: any[] = []; - - if (orConditions.length > 0) { - filter.$or = orConditions; + async getPaginated({ + page, + limit, + search, + filters, + }: GetPaginatedQuery): Promise> { + const skip: number = (page - 1) * limit; + const filter: Filter = {}; + + if (filters?.refreshStatus?.length) { + filter.$or = [ + { + refreshStatus: { + $in: filters.refreshStatus, + }, + }, + ]; } - if (search) { + const trimmedSearch: string | undefined = search?.trim(); + if (trimmedSearch) { + const escaped: string = trimmedSearch.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&'); + const regexCondition = { $regex: escaped, $options: 'i' }; + filter.$and = [ { $or: [ - { name: { $regex: search, $options: 'i' } }, - { address: { $regex: search, $options: 'i' } }, + { title: regexCondition }, + { msigAddress: regexCondition }, + { jsonNumber: regexCondition }, ], }, ]; } - const totalCount = await this._db - .collection('issueDetails') - .countDocuments(filter); - const issues = await this._db - .collection('issueDetails') - .find(filter) - .skip(skip) - .limit(limit) - .toArray(); + const collection = this._db.collection('issueDetails'); + + const [totalCount, issues] = await Promise.all([ + collection.countDocuments(filter), + collection + .find(filter, { + skip, + limit, + sort: { createdAt: -1 }, + }) + .toArray(), + ]); return { results: issues, From ae65576bd951062901df473d7e754bdc556ae08b Mon Sep 17 00:00:00 2001 From: rmagrys Date: Wed, 24 Sep 2025 16:53:34 +0200 Subject: [PATCH 2/5] added more tests --- .../get-refreshes/get-refreshes.query.test.ts | 71 +++++++++++++++++++ .../get-refreshes/get-refreshes.query.ts | 8 ++- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts diff --git a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts new file mode 100644 index 0000000..5885a6c --- /dev/null +++ b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts @@ -0,0 +1,71 @@ +import { Container } from 'inversify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GetRefreshesQuery, GetRefreshesQueryHandler } from './get-refreshes.query'; +import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; +import { TYPES } from '@src/types'; +import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; + +describe('GetRefreshesQuery', () => { + let container: Container; + let handler: GetRefreshesQueryHandler; + + const repositoryMock = { + getPaginated: vi.fn().mockResolvedValue('fixtureRepositoryResponse'), + }; + + beforeEach(() => { + container = new Container(); + container.bind(GetRefreshesQueryHandler).toSelf(); + container + .bind(TYPES.IssueDetailsRepository) + .toConstantValue(repositoryMock as unknown as IIssueDetailsRepository); + handler = container.get(GetRefreshesQueryHandler); + }); + + it('should be defined', () => { + expect(GetRefreshesQueryHandler).toBeDefined(); + }); + + it('should execute the query', async () => { + const query = new GetRefreshesQuery(1, 10, 'test', [RefreshStatus.PENDING]); + const result = await handler.execute(query); + + expect(result).toBe('fixtureRepositoryResponse'); + expect(repositoryMock.getPaginated).toHaveBeenCalledWith({ + page: 1, + limit: 10, + search: 'test', + filters: { + refreshStatus: [RefreshStatus.PENDING], + }, + }); + }); + + it('should throw an error when the repository throws an error', async () => { + const error = new Error('test error'); + repositoryMock.getPaginated.mockRejectedValue(error); + + const query = new GetRefreshesQuery(1, 10, 'test', [RefreshStatus.PENDING]); + await expect(handler.execute(query)).rejects.toThrow(error); + + expect(repositoryMock.getPaginated).toHaveBeenCalledWith({ + page: 1, + limit: 10, + search: 'test', + filters: { + refreshStatus: [RefreshStatus.PENDING], + }, + }); + }); + + it('should be called without optional parameters', async () => { + const query = new GetRefreshesQuery(); + const result = await handler.execute(query); + + expect(result).toBe('fixtureRepositoryResponse'); + expect(repositoryMock.getPaginated).toHaveBeenCalledWith({ + page: 1, + limit: 10, + }); + }); +}); diff --git a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts index 7f0c3e4..8d821ea 100644 --- a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts +++ b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.ts @@ -27,9 +27,11 @@ export class GetRefreshesQueryHandler implements IQueryHandler Date: Thu, 25 Sep 2025 11:06:01 +0200 Subject: [PATCH 3/5] failing tests --- .../queries/get-refreshes/get-refreshes.query.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts index 5885a6c..82e6335 100644 --- a/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts +++ b/packages/application/src/application/queries/get-refreshes/get-refreshes.query.test.ts @@ -1,5 +1,5 @@ import { Container } from 'inversify'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GetRefreshesQuery, GetRefreshesQueryHandler } from './get-refreshes.query'; import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; import { TYPES } from '@src/types'; @@ -10,7 +10,7 @@ describe('GetRefreshesQuery', () => { let handler: GetRefreshesQueryHandler; const repositoryMock = { - getPaginated: vi.fn().mockResolvedValue('fixtureRepositoryResponse'), + getPaginated: vi.fn(), }; beforeEach(() => { @@ -20,6 +20,12 @@ describe('GetRefreshesQuery', () => { .bind(TYPES.IssueDetailsRepository) .toConstantValue(repositoryMock as unknown as IIssueDetailsRepository); handler = container.get(GetRefreshesQueryHandler); + + repositoryMock.getPaginated.mockResolvedValue('fixtureRepositoryResponse'); + }); + + afterEach(() => { + vi.clearAllMocks(); }); it('should be defined', () => { From 40969c3dc4ac596d0df323c260280b9d27083c19 Mon Sep 17 00:00:00 2001 From: rmagrys Date: Tue, 30 Sep 2025 15:49:16 +0200 Subject: [PATCH 4/5] fixed bugs and added sync enpoint by issue number --- package.json | 3 +- .../http/controllers/refresh.controller.ts | 16 +++- packages/application/src/api/index.ts | 3 +- .../application/dtos/GovernanceReviewDto.ts | 2 +- .../src/application/dtos/SyncIssueDto.ts | 4 + .../publishers/refresh-audit-publisher.ts | 16 ++++ .../refresh-issues/approve-refresh.command.ts | 1 + .../refresh-issues/sync-issue.command.ts | 83 +++++++++++++++++++ .../refresh-issues/upsert-issue.command.ts | 9 +- .../subscribe-rkh-approvals.service.ts | 2 +- .../application/src/constants/log-messages.ts | 8 ++ .../src/constants/validation-messages.ts | 8 ++ .../src/infrastructure/clients/github.ts | 9 ++ .../infrastructure/mappers/audit-mapper.ts | 18 ++-- .../repositories/issue-details.ts | 4 +- .../decorators/signature-guard.decorator.ts | 2 +- packages/application/src/startup.ts | 4 + scripts/setup-tunnel.sh | 2 +- 18 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 packages/application/src/application/dtos/SyncIssueDto.ts create mode 100644 packages/application/src/application/use-cases/refresh-issues/sync-issue.command.ts diff --git a/package.json b/package.json index d3769cb..d39e774 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "core": "npx -w @filecoin-plus/core", "format:check": "prettier --check 'packages/**/*.{js,ts,json}'", "format:fix": "prettier --write 'packages/**/*.{js,ts,json}'", - "test:unit": "cd packages/application && npm run test:unit run" + "test:unit": "cd packages/application && npm run test:unit run", + "test:e2e": "cd packages/application && npm run test:e2e run" }, "devDependencies": { "prettier": "^3.3.3" diff --git a/packages/application/src/api/http/controllers/refresh.controller.ts b/packages/application/src/api/http/controllers/refresh.controller.ts index 83aec17..e549f2a 100644 --- a/packages/application/src/api/http/controllers/refresh.controller.ts +++ b/packages/application/src/api/http/controllers/refresh.controller.ts @@ -33,6 +33,7 @@ import { SignatureGuard } from '@src/patterns/decorators/signature-guard.decorat import { RejectRefreshCommand } from '@src/application/use-cases/refresh-issues/reject-refesh.command'; import { ApproveRefreshCommand } from '@src/application/use-cases/refresh-issues/approve-refresh.command'; import { GetRefreshesQueryDto } from '@src/application/dtos/GetRefreshesQueryDto'; +import { SyncIssueCommand } from '@src/application/use-cases/refresh-issues/sync-issue.command'; const RES = RESPONSE_MESSAGES.REFRESH_CONTROLLER; @@ -84,6 +85,19 @@ export class RefreshController { return res.json(ok(RES.UPSERTED_ISSUE)); } + @httpPost('/sync/issue/:githubIssueNumber') + async syncIssue( + @requestParam('githubIssueNumber') githubIssueNumber: string, + @response() res: Response, + ) { + const result = await this._commandBus.send(new SyncIssueCommand(parseInt(githubIssueNumber))); + + if (!result?.success) { + return res.status(400).json(badRequest(RES.FAILED_TO_UPSERT_ISSUE, [result.error.message])); + } + return res.json(ok(RES.UPSERTED_ISSUE)); + } + @httpPost('/sync/issues') async syncIssues(@response() res: Response) { const result = await this._commandBus.send(new RefreshIssuesCommand()); @@ -114,7 +128,7 @@ export class RefreshController { const command = result === 'approve' - ? new ApproveRefreshCommand(id, approveRefreshDto.details.finalDataCap) + ? new ApproveRefreshCommand(id, parseInt(approveRefreshDto.details.finalDataCap)) : new RejectRefreshCommand(id); const refreshResult = await this._commandBus.send(command); diff --git a/packages/application/src/api/index.ts b/packages/application/src/api/index.ts index ba9724c..6452298 100644 --- a/packages/application/src/api/index.ts +++ b/packages/application/src/api/index.ts @@ -147,7 +147,8 @@ async function main() { }); if (!lotusResponse.ok) { - logger.error('Lotus RPC request failed in batch', { + logger.error('Lotus RPC request failed in batch'); + logger.error({ status: lotusResponse.status, statusText: lotusResponse.statusText, method, diff --git a/packages/application/src/application/dtos/GovernanceReviewDto.ts b/packages/application/src/application/dtos/GovernanceReviewDto.ts index 3b34eba..4fc9363 100644 --- a/packages/application/src/application/dtos/GovernanceReviewDto.ts +++ b/packages/application/src/application/dtos/GovernanceReviewDto.ts @@ -1,7 +1,7 @@ export interface GovernanceReviewDetailsDto { reviewerAddress: string; reviewerPublicKey: string; - finalDataCap: number; + finalDataCap: string; allocatorType: string; } diff --git a/packages/application/src/application/dtos/SyncIssueDto.ts b/packages/application/src/application/dtos/SyncIssueDto.ts new file mode 100644 index 0000000..8970537 --- /dev/null +++ b/packages/application/src/application/dtos/SyncIssueDto.ts @@ -0,0 +1,4 @@ +export interface SyncIssueDto { + githubIssueId: string; + jsonNumber: string; +} diff --git a/packages/application/src/application/publishers/refresh-audit-publisher.ts b/packages/application/src/application/publishers/refresh-audit-publisher.ts index cc3add3..b5fd2d0 100644 --- a/packages/application/src/application/publishers/refresh-audit-publisher.ts +++ b/packages/application/src/application/publishers/refresh-audit-publisher.ts @@ -90,6 +90,11 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { }> { const { allocator } = await this.getAllocatorJsonDetails(jsonHash); + this._logger.debug(`Allocator:`); + this._logger.debug(allocator); + this._logger.debug(`Current audit:`); + this._logger.debug(allocator.audits[allocator.audits.length - 1]); + if ( expectedPreviousAuditOutcome && !expectedPreviousAuditOutcome.includes(allocator.audits.at(-1)?.outcome as AuditOutcome) @@ -99,6 +104,8 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { ); const auditDataToUpdate = typeof auditData === 'function' ? auditData(allocator!) : auditData; + this._logger.debug(`Incoming audit update:`); + this._logger.debug(auditDataToUpdate); Object.entries(this._auditMapper.partialFromAuditDataToDomain(auditDataToUpdate)).forEach( ([key, value]) => { @@ -162,6 +169,9 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { files, ); + this._logger.debug(`Pull request:`); + this._logger.debug(pr); + await this._github.mergePullRequest( this._allocatorRegistryConfig.owner, this._allocatorRegistryConfig.repo, @@ -169,12 +179,18 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { `Refresh Audit ${jsonHash} ${allocator.audits.length} - ${allocator.application_number}`, ); + this._logger.debug(`Pull request merged:`); + this._logger.debug(pr.number); + await this._github.deleteBranch( this._allocatorRegistryConfig.owner, this._allocatorRegistryConfig.repo, branchName, ); + this._logger.debug(`Branch deleted:`); + this._logger.debug(branchName); + return { branchName, commitSha: pr.head.sha, diff --git a/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts b/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts index 45aaf6a..36adb2d 100644 --- a/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts +++ b/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts @@ -76,6 +76,7 @@ export class ApproveRefreshCommandHandler implements ICommandHandler { + commandToHandle: string = SyncIssueCommand.name; + + constructor( + @inject(TYPES.Logger) private readonly logger: Logger, + @inject(TYPES.CommandBus) private readonly commandBus: ICommandBus, + @inject(TYPES.IssueMapper) private readonly issueMapper: IIssueMapper, + @inject(TYPES.GithubClient) private readonly githubClient: GithubClient, + @inject(TYPES.AllocatorGovernanceConfig) + private readonly allocatorGovernanceConfig: GithubConfig, + ) {} + + async handle(command: SyncIssueCommand) { + this.logger.info(command); + + try { + const repoIssue = await this.fetchIssueData(command.githubIssueNumber); + const mappedIssue = this.handleMapIssue(repoIssue); + const response = await this.commandBus.send(new UpsertIssueCommand(mappedIssue)); + + if (!response?.success) throw response.error; + + return { + success: true, + }; + } catch (e) { + this.logger.error(LOG.FAILED_TO_SYNC_ISSUE, e); + + return { + success: false, + error: e, + }; + } + } + + private async fetchIssueData(githubIssueNumber: number): Promise { + try { + this.logger.info(LOG.SYNCING_ISSUE); + + const issue = await this.githubClient.getIssue( + this.allocatorGovernanceConfig.owner, + this.allocatorGovernanceConfig.repo, + githubIssueNumber, + ); + + this.logger.info(LOG.ISSUE_SYNCED); + + return issue; + } catch (e) { + this.logger.error(LOG.FAILED_TO_SYNC_ISSUE, e); + throw e; + } + } + + private handleMapIssue(repoIssue: RepoIssue): IssueDetails { + this.logger.info(LOG.MAPPING_ISSUE); + + const mappedIssue = this.issueMapper.fromDomainToIssue(repoIssue); + this.logger.info(LOG.ISSUE_MAPPED); + + return mappedIssue; + } +} diff --git a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts index 325b0e2..a49116a 100644 --- a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts +++ b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts @@ -1,18 +1,13 @@ import { Command, ICommandBus, ICommandHandler, Logger } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; -import { AuditOutcome, IssueDetails } from '@src/infrastructure/repositories/issue-details'; +import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; import { TYPES } from '@src/types'; -import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; import { LOG_MESSAGES, RESPONSE_MESSAGES } from '@src/constants'; import { FetchAllocatorCommand } from '@src/application/use-cases/fetch-allocator/fetch-allocator.command'; import { IIssueMapper } from '@src/infrastructure/mappers/issue-mapper'; -import { - ApplicationPullRequestFile, - AuditCycle, -} from '@src/application/services/pull-request.types'; +import { ApplicationPullRequestFile } from '@src/application/services/pull-request.types'; import { UpsertIssueStrategyResolver } from './upsert-issue.strategy'; -import { a } from 'vitest/dist/chunks/suite.d.FvehnV49'; const LOG = LOG_MESSAGES.UPSERT_ISSUE_COMMAND; const RES = RESPONSE_MESSAGES.UPSERT_ISSUE_COMMAND; diff --git a/packages/application/src/application/use-cases/update-rkh-approvals/subscribe-rkh-approvals.service.ts b/packages/application/src/application/use-cases/update-rkh-approvals/subscribe-rkh-approvals.service.ts index 5965948..2656ccc 100644 --- a/packages/application/src/application/use-cases/update-rkh-approvals/subscribe-rkh-approvals.service.ts +++ b/packages/application/src/application/use-cases/update-rkh-approvals/subscribe-rkh-approvals.service.ts @@ -188,7 +188,7 @@ export async function handleSignRKHRefresh({ address: string; issuesRepository: IIssueDetailsRepository; }) { - const issue = await issuesRepository.findSignedBy({ msigAddress: address }); + const issue = await issuesRepository.findApprovedBy({ msigAddress: address }); if (!issue) throw new Error(`Issue not found for address ${address}`); return new SignRefreshByRKHCommand(issue, tx); diff --git a/packages/application/src/constants/log-messages.ts b/packages/application/src/constants/log-messages.ts index 0c84c3d..7d33811 100644 --- a/packages/application/src/constants/log-messages.ts +++ b/packages/application/src/constants/log-messages.ts @@ -93,4 +93,12 @@ export const LOG_MESSAGES = { NEW_AUDIT_PUBLISHED: '[RefreshAuditPublisher]: New audit published', AUDIT_UPDATED: '[RefreshAuditPublisher]: Audit updated', }, + + SYNC_ISSUE_COMMAND: { + SYNCING_ISSUE: '[SyncIssueCommand]: Syncing issue', + ISSUE_SYNCED: '[SyncIssueCommand]: Issue synced successfully', + FAILED_TO_SYNC_ISSUE: '[SyncIssueCommand]: Failed to sync issue', + MAPPING_ISSUE: '[SyncIssueCommand]: Mapping issue', + ISSUE_MAPPED: '[SyncIssueCommand]: Issue mapped successfully', + }, }; diff --git a/packages/application/src/constants/validation-messages.ts b/packages/application/src/constants/validation-messages.ts index 4e975a8..5ec8486 100644 --- a/packages/application/src/constants/validation-messages.ts +++ b/packages/application/src/constants/validation-messages.ts @@ -77,4 +77,12 @@ export const VALIDATION_MESSAGES = { INVALID_VALUE: 'Query status item must be a valid status', }, }, + ISSUE_JSON_NUMBER: { + REQUIRED: 'Issue JSON number is required', + INVALID: 'Issue JSON number must be a string', + }, + ISSUE_GITHUB_ISSUE_ID: { + REQUIRED: 'Issue GitHub issue ID is required', + INVALID: 'Issue GitHub issue ID must be a positive integer', + }, }; diff --git a/packages/application/src/infrastructure/clients/github.ts b/packages/application/src/infrastructure/clients/github.ts index bd2a761..0166fbe 100644 --- a/packages/application/src/infrastructure/clients/github.ts +++ b/packages/application/src/infrastructure/clients/github.ts @@ -423,6 +423,15 @@ export class GithubClient implements IGithubClient { } } + async getIssue(owner: string, repo: string, issueNumber: number): Promise { + const { data } = await this.octokit.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + return data; + } + async createOrUpdateFile( owner: string, repo: string, diff --git a/packages/application/src/infrastructure/mappers/audit-mapper.ts b/packages/application/src/infrastructure/mappers/audit-mapper.ts index a3faea1..4c8d50c 100644 --- a/packages/application/src/infrastructure/mappers/audit-mapper.ts +++ b/packages/application/src/infrastructure/mappers/audit-mapper.ts @@ -13,6 +13,14 @@ export interface IAuditMapper { @injectable() export class AuditMapper implements IAuditMapper { + private readonly toDomainMapping: Record = { + started: 'started', + ended: 'ended', + dcAllocated: 'dc_allocated', + outcome: 'outcome', + datacapAmount: 'datacap_amount', + }; + fromDomainToAuditData(jsonAuditCycle: AuditCycle): AuditData { return { started: jsonAuditCycle.started, @@ -34,12 +42,8 @@ export class AuditMapper implements IAuditMapper { } partialFromAuditDataToDomain(auditData: Partial): Partial { - return { - started: auditData.started, - ended: auditData.ended, - dc_allocated: auditData.dcAllocated, - outcome: auditData.outcome as AuditOutcome, - datacap_amount: auditData.datacapAmount as number, - }; + return Object.fromEntries( + Object.entries(auditData).map(([key, value]) => [this.toDomainMapping[key], value]), + ); } } diff --git a/packages/application/src/infrastructure/repositories/issue-details.ts b/packages/application/src/infrastructure/repositories/issue-details.ts index 810350f..28d8e4d 100644 --- a/packages/application/src/infrastructure/repositories/issue-details.ts +++ b/packages/application/src/infrastructure/repositories/issue-details.ts @@ -55,12 +55,12 @@ export type AuditData = { datacapAmount: number | ''; }; -interface RkhPhase { +export interface RkhPhase { messageId: number; approvals: string[]; } -interface MetaAllocator { +export interface MetaAllocator { blockNumber: number; } diff --git a/packages/application/src/patterns/decorators/signature-guard.decorator.ts b/packages/application/src/patterns/decorators/signature-guard.decorator.ts index 5f01cf8..e8e2231 100644 --- a/packages/application/src/patterns/decorators/signature-guard.decorator.ts +++ b/packages/application/src/patterns/decorators/signature-guard.decorator.ts @@ -51,7 +51,7 @@ export function SignatureGuard(signatureType: SignatureType): HandlerDecorator { const expectedPreImage = messageFactoryByType[signatureType]({ result, id, - finalDataCap, + finalDataCap: parseInt(finalDataCap), allocatorType, }); diff --git a/packages/application/src/startup.ts b/packages/application/src/startup.ts index 612e24b..2ec34e5 100644 --- a/packages/application/src/startup.ts +++ b/packages/application/src/startup.ts @@ -84,6 +84,7 @@ import { SaveIssueWithNewAuditCommandHandler } from './application/use-cases/ref import { SaveIssueCommandHandler } from './application/use-cases/refresh-issues/save-issue.command'; import { ApproveRefreshCommandHandler } from './application/use-cases/refresh-issues/approve-refresh.command'; import { RejectRefreshCommandHandler } from './application/use-cases/refresh-issues/reject-refesh.command'; +import { SyncIssueCommandCommandHandler } from './application/use-cases/refresh-issues/sync-issue.command'; export const initialize = async (): Promise => { const container = new Container(); @@ -196,6 +197,9 @@ export const initialize = async (): Promise => { container.bind>(TYPES.CommandHandler).to(SaveIssueCommandHandler); container.bind>(TYPES.CommandHandler).to(ApproveRefreshCommandHandler); container.bind>(TYPES.CommandHandler).to(RejectRefreshCommandHandler); + container + .bind>(TYPES.CommandHandler) + .to(SyncIssueCommandCommandHandler); const commandBus = container.get(TYPES.CommandBus); container diff --git a/scripts/setup-tunnel.sh b/scripts/setup-tunnel.sh index aff2df1..dd7946a 100755 --- a/scripts/setup-tunnel.sh +++ b/scripts/setup-tunnel.sh @@ -45,7 +45,7 @@ npx localtunnel --port "$PORT" > "$temp_file" 2>&1 & tunnel_pid=$! echo "Waiting for tunnel URL..." -sleep 5 +sleep 10 tunnel_url=$(grep -o 'https://[^[:space:]]*\.loca\.lt' "$temp_file" | head -1) From 5e8eaac22fc805abd9119503b565277383849629 Mon Sep 17 00:00:00 2001 From: rmagrys Date: Tue, 30 Sep 2025 15:49:21 +0200 Subject: [PATCH 5/5] tests --- .../refresh-audit-publisher.test.ts | 27 ++++- .../approve-refresh.command.test.ts | 89 ++++++++++++++ .../refresh-issues/sync-issue.command.test.ts | 113 ++++++++++++++++++ .../post--governance-review.e2e.test.ts | 1 + .../mappers/audit-mapper.test.ts | 101 ++++++++++++++++ 5 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.test.ts create mode 100644 packages/application/src/application/use-cases/refresh-issues/sync-issue.command.test.ts create mode 100644 packages/application/src/infrastructure/mappers/audit-mapper.test.ts diff --git a/packages/application/src/application/publishers/refresh-audit-publisher.test.ts b/packages/application/src/application/publishers/refresh-audit-publisher.test.ts index 9c854c7..720fa04 100644 --- a/packages/application/src/application/publishers/refresh-audit-publisher.test.ts +++ b/packages/application/src/application/publishers/refresh-audit-publisher.test.ts @@ -38,7 +38,7 @@ describe('RefreshAuditPublisher', () => { datacap_amount: a.datacapAmount as number, })), }; - const loggerMock = { info: vi.fn(), error: vi.fn() }; + const loggerMock = { info: vi.fn(), error: vi.fn(), debug: vi.fn() }; const baseAllocator = { application_number: 123, @@ -115,7 +115,7 @@ describe('RefreshAuditPublisher', () => { ); it('updateAudit applies partial changes to latest audit and publishes PR', async () => { - const change = { ended: '2024-01-02T00:00:00.000Z', outcome: AuditOutcome.APPROVED } as any; + const change = { ended: '2024-01-02T00:00:00.000Z', outcome: AuditOutcome.APPROVED }; const result = await publisher.updateAudit('hash123', change); @@ -123,5 +123,28 @@ describe('RefreshAuditPublisher', () => { expect(githubMock.createPullRequest).toHaveBeenCalled(); expect(result.prNumber).toBe(10); expect(result.auditChange).toEqual(change); + expect(loggerMock.info).toHaveBeenCalledWith( + `[RefreshAuditPublisher]: Audit updated jsonHash: hash123 outcome: APPROVED`, + ); + expect(loggerMock.debug).toHaveBeenCalledWith(`Allocator:`); + expect(loggerMock.debug).toHaveBeenCalledWith(baseAllocator); + expect(loggerMock.debug).toHaveBeenCalledWith(`Current audit:`); + expect(loggerMock.debug).toHaveBeenCalledWith( + baseAllocator.audits[baseAllocator.audits.length - 1], + ); + expect(loggerMock.debug).toHaveBeenCalledWith(`Incoming audit update:`); + expect(loggerMock.debug).toHaveBeenCalledWith(change); + expect(loggerMock.debug).toHaveBeenCalledWith(`Pull request:`); + expect(loggerMock.debug).toHaveBeenCalledWith(result.prNumber); + expect(loggerMock.debug).toHaveBeenCalledWith(`Branch deleted:`); + expect(loggerMock.debug).toHaveBeenCalledWith(result.branchName); + }); + + it('should preserve fields with empty string values', async () => { + const change = { ended: '2024-01-02T00:00:00.000Z', outcome: AuditOutcome.APPROVED } as any; + + const result = await publisher.updateAudit('hash123', change); + + expect(result.auditChange).toEqual(change); }); }); diff --git a/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.test.ts b/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.test.ts new file mode 100644 index 0000000..ef31899 --- /dev/null +++ b/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.test.ts @@ -0,0 +1,89 @@ +import { Container } from 'inversify'; +import { ApproveRefreshCommand, ApproveRefreshCommandHandler } from './approve-refresh.command'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { Logger } from '@filecoin-plus/core'; +import { TYPES } from '@src/types'; +import { ICommandBus } from '@filecoin-plus/core'; +import { RefreshAuditService } from '@src/application/services/refresh-audit.service'; +import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; +import { DatabaseRefreshFactory } from '@mocks/factories'; +import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; +import { SaveIssueCommand } from './save-issue.command'; + +vi.mock('nanoid', () => ({ + nanoid: vi.fn().mockReturnValue('nanoid-id'), +})); + +describe('ApproveRefreshCommandHandler', () => { + let container: Container; + let handler: ApproveRefreshCommandHandler; + + const loggerMock = { info: vi.fn(), error: vi.fn() }; + const commandBusMock = { send: vi.fn() }; + const refreshAuditServiceMock = { approveAudit: vi.fn() }; + const issueDetailsRepositoryMock = { findPendingBy: vi.fn() }; + + const fixtureJsonNumber = 'rec512s579f'; + const fixtureIssueDetails = DatabaseRefreshFactory.create({ + refreshStatus: RefreshStatus.PENDING, + jsonNumber: fixtureJsonNumber, + }); + const fixtureApprovedAuditResult = { + auditChange: { + ended: '2024-01-01T00:00:00.000Z', + outcome: 'APPROVED', + datacapAmount: 100, + }, + }; + + beforeEach(() => { + container = new Container(); + container.bind(TYPES.Logger).toConstantValue(loggerMock as unknown as Logger); + container + .bind(TYPES.CommandBus) + .toConstantValue(commandBusMock as unknown as ICommandBus); + container + .bind(TYPES.RefreshAuditService) + .toConstantValue(refreshAuditServiceMock as unknown as RefreshAuditService); + container + .bind(TYPES.IssueDetailsRepository) + .toConstantValue(issueDetailsRepositoryMock as unknown as IIssueDetailsRepository); + container.bind(ApproveRefreshCommandHandler).toSelf(); + handler = container.get(ApproveRefreshCommandHandler); + + issueDetailsRepositoryMock.findPendingBy.mockResolvedValue(fixtureIssueDetails); + refreshAuditServiceMock.approveAudit.mockResolvedValue(fixtureApprovedAuditResult); + commandBusMock.send.mockResolvedValue({ success: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should approve a refresh', async () => { + const command = new ApproveRefreshCommand(1, 100); + const result = await handler.handle(command); + + expect(issueDetailsRepositoryMock.findPendingBy).toHaveBeenCalledWith({ + githubIssueNumber: 1, + }); + + expect(refreshAuditServiceMock.approveAudit).toHaveBeenCalledWith(fixtureJsonNumber, 100); + + expect(commandBusMock.send).toHaveBeenCalledWith(expect.any(SaveIssueCommand)); + expect(commandBusMock.send).toHaveBeenCalledWith({ + guid: 'nanoid-id', + issueDetails: { + ...fixtureIssueDetails, + refreshStatus: RefreshStatus.APPROVED, + dataCap: 100, + auditHistory: [fixtureApprovedAuditResult], + }, + }); + + expect(result).toStrictEqual({ + success: true, + }); + expect(loggerMock.info).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/application/src/application/use-cases/refresh-issues/sync-issue.command.test.ts b/packages/application/src/application/use-cases/refresh-issues/sync-issue.command.test.ts new file mode 100644 index 0000000..e38ae8f --- /dev/null +++ b/packages/application/src/application/use-cases/refresh-issues/sync-issue.command.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Logger } from '@filecoin-plus/core'; +import { TYPES } from '@src/types'; +import { ICommandBus } from '@filecoin-plus/core/src/interfaces/ICommandBus'; +import { IGithubClient } from '@src/infrastructure/clients/github'; +import { GithubConfig } from '@src/domain/types'; +import { Container } from 'inversify'; +import { SyncIssueCommand, SyncIssueCommandCommandHandler } from './sync-issue.command'; +import { IIssueMapper } from '@src/infrastructure/mappers/issue-mapper'; +import { GithubIssueFactory } from '@src/testing/mocks/factories'; +import { UpsertIssueCommand } from './upsert-issue.command'; + +vi.mock('nanoid', () => ({ + nanoid: vi.fn().mockReturnValue('guid'), +})); + +describe('SyncIssueCommand', () => { + let container: Container; + let handler: SyncIssueCommandCommandHandler; + + const loggerMock = { info: vi.fn(), error: vi.fn() }; + const commandBusMock = { send: vi.fn() }; + const githubClientMock = { getIssue: vi.fn() }; + const allocatorGovernanceConfigMock = { owner: 'owner', repo: 'repo' }; + const issueMapperMock = { fromDomainToIssue: vi.fn() }; + + const fixtureIssueDetails = GithubIssueFactory.createOpened().issue; + + beforeEach(() => { + container = new Container(); + container.bind(TYPES.Logger).toConstantValue(loggerMock as unknown as Logger); + container + .bind(TYPES.CommandBus) + .toConstantValue(commandBusMock as unknown as ICommandBus); + container + .bind(TYPES.GithubClient) + .toConstantValue(githubClientMock as unknown as IGithubClient); + container + .bind(TYPES.AllocatorGovernanceConfig) + .toConstantValue(allocatorGovernanceConfigMock as unknown as GithubConfig); + container.bind(SyncIssueCommandCommandHandler).toSelf(); + container + .bind(TYPES.IssueMapper) + .toConstantValue(issueMapperMock as unknown as IIssueMapper); + handler = container.get(SyncIssueCommandCommandHandler); + + vi.clearAllMocks(); + + commandBusMock.send.mockResolvedValue({ + data: fixtureIssueDetails, + success: true, + }); + + githubClientMock.getIssue.mockResolvedValue(fixtureIssueDetails); + issueMapperMock.fromDomainToIssue.mockReturnValue('mappedIssue'); + }); + + it('should be defined', () => { + expect(handler).toBeDefined(); + }); + + it('should successfully sync issue', async () => { + const command = new SyncIssueCommand(1); + const result = await handler.handle(command); + + expect(githubClientMock.getIssue).toHaveBeenCalledWith( + allocatorGovernanceConfigMock.owner, + allocatorGovernanceConfigMock.repo, + 1, + ); + expect(commandBusMock.send).toHaveBeenCalledWith(expect.any(UpsertIssueCommand)); + expect(commandBusMock.send).toHaveBeenCalledWith({ + githubIssue: 'mappedIssue', + guid: 'guid', + }); + expect(result).toStrictEqual({ success: true }); + }); + + it('should handle error when fetching issue fails', async () => { + const error = new Error('Failed to fetch'); + githubClientMock.getIssue.mockRejectedValue(error); + + const command = new SyncIssueCommand(1); + const result = await handler.handle(command); + expect(result).toStrictEqual({ success: false, error }); + expect(githubClientMock.getIssue).toHaveBeenCalledWith( + allocatorGovernanceConfigMock.owner, + allocatorGovernanceConfigMock.repo, + 1, + ); + expect(commandBusMock.send).not.toHaveBeenCalled(); + }); + + it('should handle error when upserting issue fails', async () => { + const error = new Error('Failed to upsert'); + commandBusMock.send.mockResolvedValue({ error, success: false }); + + const command = new SyncIssueCommand(1); + const result = await handler.handle(command); + expect(result).toStrictEqual({ success: false, error }); + expect(githubClientMock.getIssue).toHaveBeenCalledWith( + allocatorGovernanceConfigMock.owner, + allocatorGovernanceConfigMock.repo, + 1, + ); + expect(commandBusMock.send).toHaveBeenCalledWith(expect.any(UpsertIssueCommand)); + expect(loggerMock.error).toHaveBeenCalled(); + expect(loggerMock.error).toHaveBeenCalledWith( + '[SyncIssueCommand]: Failed to sync issue', + error, + ); + }); +}); diff --git a/packages/application/src/e2e/api/refresh/post--governance-review.e2e.test.ts b/packages/application/src/e2e/api/refresh/post--governance-review.e2e.test.ts index b16544b..cd95d3e 100644 --- a/packages/application/src/e2e/api/refresh/post--governance-review.e2e.test.ts +++ b/packages/application/src/e2e/api/refresh/post--governance-review.e2e.test.ts @@ -220,6 +220,7 @@ describe('POST /api/v1/refreshes/:githubIssueId/review', () => { expect(refresh).toStrictEqual({ ...fixturePendingRefresh, _id: databasePendingRefresh.insertedId, + dataCap: fixtureChallengeProps.finalDataCap, refreshStatus: RefreshStatus.APPROVED, auditHistory: [ ...(fixturePendingRefresh.auditHistory || []), diff --git a/packages/application/src/infrastructure/mappers/audit-mapper.test.ts b/packages/application/src/infrastructure/mappers/audit-mapper.test.ts new file mode 100644 index 0000000..1fd10fe --- /dev/null +++ b/packages/application/src/infrastructure/mappers/audit-mapper.test.ts @@ -0,0 +1,101 @@ +import { Container } from 'inversify'; +import { AuditMapper } from './audit-mapper'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { AuditData, AuditOutcome } from '../repositories/issue-details'; + +describe('AuditMapper', () => { + let container: Container; + let auditMapper: AuditMapper; + beforeEach(() => { + container = new Container(); + container.bind(AuditMapper).to(AuditMapper); + auditMapper = container.get(AuditMapper); + }); + + it('fromDomainToAuditData', () => { + const auditCycle = { + started: '2023-01-01T00:00:00.000Z', + ended: '2023-01-02T00:00:00.000Z', + dc_allocated: '2023-01-03T00:00:00.000Z', + outcome: AuditOutcome.APPROVED, + datacap_amount: 1, + }; + + expect(auditMapper.fromDomainToAuditData(auditCycle)).toEqual({ + started: auditCycle.started, + ended: auditCycle.ended, + dcAllocated: auditCycle.dc_allocated, + outcome: auditCycle.outcome, + datacapAmount: auditCycle.datacap_amount, + }); + }); + + it('fromAuditDataToDomain', () => { + const auditData = { + started: '2023-01-01T00:00:00.000Z', + ended: '2023-01-02T00:00:00.000Z', + dcAllocated: '2023-01-03T00:00:00.000Z', + outcome: AuditOutcome.APPROVED, + datacapAmount: 1, + }; + + expect(auditMapper.fromAuditDataToDomain(auditData)).toEqual({ + started: auditData.started, + ended: auditData.ended, + dc_allocated: auditData.dcAllocated, + outcome: auditData.outcome, + datacap_amount: auditData.datacapAmount, + }); + }); + + it('partialFromAuditDataToDomain', () => { + const newAuditData: AuditData = { + ended: '', + dcAllocated: '', + datacapAmount: '', + started: '2023-01-01T00:00:00.000Z', + outcome: AuditOutcome.PENDING, + }; + const approvedAuditData: Partial = { + started: '2023-01-01T00:00:00.000Z', + outcome: AuditOutcome.APPROVED, + datacapAmount: 1, + }; + const rejectedAuditData: Partial = { + started: '2023-01-01T00:00:00.000Z', + outcome: AuditOutcome.REJECTED, + }; + const finishedAuditData: Partial = { + started: '2023-01-01T00:00:00.000Z', + outcome: AuditOutcome.GRANTED, + datacapAmount: 1, + }; + + expect(auditMapper.partialFromAuditDataToDomain(approvedAuditData)).toEqual({ + started: approvedAuditData.started, + outcome: approvedAuditData.outcome, + datacap_amount: approvedAuditData.datacapAmount, + }); + + expect(auditMapper.partialFromAuditDataToDomain(newAuditData)).toEqual({ + ended: newAuditData.ended, + dc_allocated: newAuditData.dcAllocated, + datacap_amount: newAuditData.datacapAmount, + started: newAuditData.started, + outcome: newAuditData.outcome, + }); + + expect(auditMapper.partialFromAuditDataToDomain(finishedAuditData)).toEqual({ + started: finishedAuditData.started, + outcome: finishedAuditData.outcome, + datacap_amount: finishedAuditData.datacapAmount, + }); + + expect(auditMapper.partialFromAuditDataToDomain(rejectedAuditData)).toEqual({ + started: rejectedAuditData.started, + outcome: rejectedAuditData.outcome, + }); + + expect(auditMapper.partialFromAuditDataToDomain({})).toEqual({}); + }); +});