diff --git a/api/src/credits/credit.repository.spec.ts b/api/src/credits/credit.repository.spec.ts index c97c44d..af8953e 100644 --- a/api/src/credits/credit.repository.spec.ts +++ b/api/src/credits/credit.repository.spec.ts @@ -2,7 +2,7 @@ import { InMemoryCreditRepository } from './credit.repository'; import { CreditEntity } from './credit.entity'; import { CreditStatus } from '../shared'; -function makeCredit(id: string, projectId = 'PROJ-001'): CreditEntity { +function makeCredit(id: string, projectId = 'PROJ-001', status = CreditStatus.Active): CreditEntity { const e = new CreditEntity(); e.id = id; e.projectId = projectId; @@ -12,7 +12,7 @@ function makeCredit(id: string, projectId = 'PROJ-001'): CreditEntity { e.geography = 'NG'; e.tonnes = '1000000'; e.ipfsHash = 'baf'; - e.status = CreditStatus.Active; + e.status = status; e.issuedAt = 1700000000; return e; } @@ -51,4 +51,75 @@ describe('InMemoryCreditRepository', () => { expect(result.data).toHaveLength(1); expect(result.data[0].id).toBe('a'); }); + + // ── findByStatus — one test per CreditStatus value ─────────────────────── + + it('findByStatus returns only Active credits', async () => { + await repo.save(makeCredit('active1', 'P', CreditStatus.Active)); + await repo.save(makeCredit('active2', 'P', CreditStatus.Active)); + await repo.save(makeCredit('retired1', 'P', CreditStatus.Retired)); + await repo.save(makeCredit('flagged1', 'P', CreditStatus.Flagged)); + await repo.save(makeCredit('pending1', 'P', CreditStatus.Pending)); + + const result = await repo.findByStatus(CreditStatus.Active, 1, 10); + expect(result.total).toBe(2); + expect(result.data.every((c) => c.status === CreditStatus.Active)).toBe(true); + expect(result.data.map((c) => c.id).sort()).toEqual(['active1', 'active2']); + }); + + it('findByStatus returns only Retired credits', async () => { + await repo.save(makeCredit('active1', 'P', CreditStatus.Active)); + await repo.save(makeCredit('retired1', 'P', CreditStatus.Retired)); + await repo.save(makeCredit('retired2', 'P', CreditStatus.Retired)); + + const result = await repo.findByStatus(CreditStatus.Retired, 1, 10); + expect(result.total).toBe(2); + expect(result.data.every((c) => c.status === CreditStatus.Retired)).toBe(true); + expect(result.data.map((c) => c.id).sort()).toEqual(['retired1', 'retired2']); + }); + + it('findByStatus returns only Flagged credits', async () => { + await repo.save(makeCredit('active1', 'P', CreditStatus.Active)); + await repo.save(makeCredit('flagged1', 'P', CreditStatus.Flagged)); + await repo.save(makeCredit('flagged2', 'P', CreditStatus.Flagged)); + + const result = await repo.findByStatus(CreditStatus.Flagged, 1, 10); + expect(result.total).toBe(2); + expect(result.data.every((c) => c.status === CreditStatus.Flagged)).toBe(true); + expect(result.data.map((c) => c.id).sort()).toEqual(['flagged1', 'flagged2']); + }); + + it('findByStatus returns only Pending credits', async () => { + await repo.save(makeCredit('pending1', 'P', CreditStatus.Pending)); + await repo.save(makeCredit('active1', 'P', CreditStatus.Active)); + + const result = await repo.findByStatus(CreditStatus.Pending, 1, 10); + expect(result.total).toBe(1); + expect(result.data[0].id).toBe('pending1'); + expect(result.data[0].status).toBe(CreditStatus.Pending); + }); + + it('findByStatus returns empty result when no credits match', async () => { + await repo.save(makeCredit('active1', 'P', CreditStatus.Active)); + + const result = await repo.findByStatus(CreditStatus.Retired, 1, 10); + expect(result.total).toBe(0); + expect(result.data).toHaveLength(0); + }); + + it('findByStatus paginates correctly', async () => { + for (let i = 0; i < 5; i++) { + await repo.save(makeCredit(`active${i}`, 'P', CreditStatus.Active)); + } + + const page1 = await repo.findByStatus(CreditStatus.Active, 1, 3); + expect(page1.data).toHaveLength(3); + expect(page1.total).toBe(5); + expect(page1.page).toBe(1); + + const page2 = await repo.findByStatus(CreditStatus.Active, 2, 3); + expect(page2.data).toHaveLength(2); + expect(page2.total).toBe(5); + expect(page2.page).toBe(2); + }); }); diff --git a/api/src/credits/credit.repository.ts b/api/src/credits/credit.repository.ts index f19a7e0..7cbec45 100644 --- a/api/src/credits/credit.repository.ts +++ b/api/src/credits/credit.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CreditEntity } from './credit.entity'; +import { CreditStatus } from '../shared'; export interface PageResult { data: T[]; @@ -13,6 +14,11 @@ export interface ICreditRepository { findById(id: string): Promise; findByProject(projectId: string, page: number, limit: number): Promise>; findAll(page: number, limit: number): Promise>; + /** + * Return a paginated list of credits whose status matches `status`. + * When `status` is omitted the caller is responsible for applying a default. + */ + findByStatus(status: CreditStatus, page: number, limit: number): Promise>; } export const CREDIT_REPOSITORY = 'CREDIT_REPOSITORY'; @@ -43,6 +49,11 @@ export class InMemoryCreditRepository implements ICreditRepository { return this.paginate(Array.from(this.store.values()), page, limit); } + async findByStatus(status: CreditStatus, page: number, limit: number): Promise> { + const all = Array.from(this.store.values()).filter((c) => c.status === status); + return this.paginate(all, page, limit); + } + private paginate(items: CreditEntity[], page: number, limit: number): PageResult { const offset = (page - 1) * limit; return { diff --git a/api/src/credits/credits.service.ts b/api/src/credits/credits.service.ts index 6ddd7d3..541b62b 100644 --- a/api/src/credits/credits.service.ts +++ b/api/src/credits/credits.service.ts @@ -173,7 +173,10 @@ export class CreditsService { async listCredits( filter: ListCreditsFilter, ): Promise<{ data: CreditMetadata[]; total: number; page: number; limit: number }> { - this.logger.log(`Listing credits with filters: ${JSON.stringify(filter)}`); + // Default to Active-only when no status is requested, so Retired and Flagged + // credits are never included unless the caller explicitly opts in. + const effectiveStatus: string = filter.status ?? CreditStatus.Active; + const effectiveFilter = { ...filter, status: effectiveStatus }; // Default to Active when client does not provide a status filter if (!filter.status) { @@ -203,18 +206,18 @@ export class CreditsService { allCredits = []; } - // Apply filters - let filtered = allCredits; + // Apply secondary filters + let filtered = candidates; if (filter.methodology) { filtered = filtered.filter( - (c) => c.methodology.toLowerCase() === filter.methodology?.toLowerCase(), + (c) => c.methodology.toLowerCase() === filter.methodology!.toLowerCase(), ); } if (filter.geography) { filtered = filtered.filter( - (c) => c.geography.toLowerCase() === filter.geography?.toLowerCase(), + (c) => c.geography.toLowerCase() === filter.geography!.toLowerCase(), ); } @@ -222,12 +225,6 @@ export class CreditsService { filtered = filtered.filter((c) => c.vintage_year === filter.vintageYear); } - if (filter.status) { - filtered = filtered.filter( - (c) => c.status.toLowerCase() === filter.status?.toLowerCase(), - ); - } - if (filter.minTonnes) { const minVal = BigInt(filter.minTonnes); filtered = filtered.filter((c) => BigInt(c.tonnes) >= minVal); @@ -240,8 +237,7 @@ export class CreditsService { const total = filtered.length; const start = (filter.page - 1) * filter.limit; - const end = start + filter.limit; - const data = filtered.slice(start, end); + const data = filtered.slice(start, start + filter.limit); const result = { data, total, page: filter.page, limit: filter.limit }; await this.cache.set(cacheKey, result, CREDIT_TTL); diff --git a/api/src/retirement/retirement.module.ts b/api/src/retirement/retirement.module.ts index f2760c7..deb2763 100644 --- a/api/src/retirement/retirement.module.ts +++ b/api/src/retirement/retirement.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { RetirementService } from './retirement.service'; +import { EventEmitter } from 'events'; +import { RetirementService, EVENT_EMITTER } from './retirement.service'; import { RetirementController } from './retirement.controller'; import { CertificateService } from './certificate.service'; import { StellarModule } from '../stellar/stellar.module'; @@ -12,7 +13,12 @@ import { InMemoryRetirementRepository, RETIREMENT_REPOSITORY } from './retiremen controllers: [RetirementController], providers: [ RetirementService, + CertificateService, { provide: RETIREMENT_REPOSITORY, useClass: InMemoryRetirementRepository }, + { + provide: EVENT_EMITTER, + useValue: new EventEmitter(), + }, ], exports: [RetirementService], }) diff --git a/api/src/retirement/retirement.service.spec.ts b/api/src/retirement/retirement.service.spec.ts new file mode 100644 index 0000000..1f1a20e --- /dev/null +++ b/api/src/retirement/retirement.service.spec.ts @@ -0,0 +1,159 @@ +/** + * Unit tests for RetirementService — focusing on the event-ordering guarantee + * described in issue #162: + * + * The `CreditRetired` event MUST only be emitted after the retirement record + * has been successfully persisted to the repository. If the repository write + * fails the event must NOT be emitted. + */ +import { RetirementService, RetireDto, CreditRetiredEvent, EVENT_EMITTER, IEventEmitter } from './retirement.service'; +import { InMemoryRetirementRepository, RETIREMENT_REPOSITORY } from './retirement.repository'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +// ── Minimal stubs ───────────────────────────────────────────────────────────── + +const mockStellarService = { + invokeContract: jest.fn().mockResolvedValue({ returnValue: null }), + readContract: jest.fn(), + getContractEvents: jest.fn().mockResolvedValue([]), +}; + +const mockKeypairService = { + getAdminKeypair: jest.fn().mockReturnValue({}), +}; + +const mockConfigService = { + get: jest.fn().mockReturnValue(''), +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeDto(overrides: Partial = {}): RetireDto { + return { + buyerPublicKey: 'GBUYER123', + creditId: 'aabbccdd', + tonnes: '1000000', + reason: '2024 Scope 3 offset', + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('RetirementService — event ordering (issue #162)', () => { + let service: RetirementService; + let repo: InMemoryRetirementRepository; + let emittedEvents: Array<{ event: string; payload: unknown }>; + let eventEmitter: IEventEmitter; + + beforeEach(async () => { + emittedEvents = []; + eventEmitter = { + emit(event: string, payload: unknown): boolean { + emittedEvents.push({ event, payload }); + return true; + }, + }; + + repo = new InMemoryRetirementRepository(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RetirementService, + { provide: 'StellarService', useValue: mockStellarService }, + { provide: 'StellarKeypairService', useValue: mockKeypairService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: RETIREMENT_REPOSITORY, useValue: repo }, + { provide: EVENT_EMITTER, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(RetirementService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('persists the retirement record before emitting CreditRetired', async () => { + const order: string[] = []; + + // Spy on repo.save to record when the write happens + const originalSave = repo.save.bind(repo); + repo.save = jest.fn().mockImplementation(async (entity) => { + const result = await originalSave(entity); + order.push('save'); + return result; + }); + + // Replace emitter to record when the event fires + eventEmitter.emit = jest.fn().mockImplementation((event: string, payload: unknown) => { + emittedEvents.push({ event, payload }); + order.push('emit'); + return true; + }); + + await service.retire(makeDto()); + + expect(order).toEqual(['save', 'emit']); + }); + + it('record exists in repository when CreditRetired event is emitted', async () => { + let recordExistedAtEmitTime = false; + + eventEmitter.emit = jest.fn().mockImplementation(async (event: string) => { + if (event === 'CreditRetired') { + // At the moment the event fires, the record must already be in the repo + const all = await repo.findAll(1, 100); + recordExistedAtEmitTime = all.total > 0; + } + return true; + }); + + await service.retire(makeDto()); + + expect(recordExistedAtEmitTime).toBe(true); + }); + + it('does NOT emit CreditRetired when the repository write fails', async () => { + repo.save = jest.fn().mockRejectedValue(new Error('DB write failed')); + + await expect(service.retire(makeDto())).rejects.toThrow('DB write failed'); + + const creditRetiredEvents = emittedEvents.filter((e) => e.event === 'CreditRetired'); + expect(creditRetiredEvents).toHaveLength(0); + }); + + it('emits exactly one CreditRetired event per retire call', async () => { + await service.retire(makeDto()); + + const creditRetiredEvents = emittedEvents.filter((e) => e.event === 'CreditRetired'); + expect(creditRetiredEvents).toHaveLength(1); + }); + + it('CreditRetired event payload contains the correct retirement data', async () => { + const dto = makeDto({ creditId: 'deadbeef', tonnes: '500000', buyerPublicKey: 'GBUYER999' }); + + await service.retire(dto); + + const event = emittedEvents.find((e) => e.event === 'CreditRetired'); + expect(event).toBeDefined(); + const payload = event!.payload as CreditRetiredEvent; + expect(payload.creditId).toBe('deadbeef'); + expect(payload.tonnesRetired).toBe('500000'); + expect(payload.buyer).toBe('GBUYER999'); + expect(typeof payload.retiredAt).toBe('number'); + expect(payload.retiredAt).toBeGreaterThan(0); + }); + + it('retirement record is retrievable from repo after retire completes', async () => { + const { retirementId } = await service.retire(makeDto()); + + const record = await repo.findById(retirementId); + expect(record).toBeDefined(); + expect(record!.creditId).toBe('aabbccdd'); + expect(record!.buyer).toBe('GBUYER123'); + expect(record!.tonnesRetired).toBe('1000000'); + }); +}); diff --git a/api/src/retirement/retirement.service.ts b/api/src/retirement/retirement.service.ts index e521c75..445ff10 100644 --- a/api/src/retirement/retirement.service.ts +++ b/api/src/retirement/retirement.service.ts @@ -32,6 +32,26 @@ export interface CertificateVerification { ledger_sequence?: number; } +/** Payload carried by the CreditRetired application event. */ +export interface CreditRetiredEvent { + retirementId: string; + creditId: string; + buyer: string; + tonnesRetired: string; + retiredAt: number; +} + +/** + * Minimal event-emitter interface so the service can be tested without a full + * NestJS EventEmitter2 module. In production the real EventEmitter2 instance + * is injected; in tests a simple stub is used. + */ +export interface IEventEmitter { + emit(event: string, payload: unknown): boolean; +} + +export const EVENT_EMITTER = 'EVENT_EMITTER'; + @Injectable() export class RetirementService { private readonly logger = new Logger(RetirementService.name); @@ -43,6 +63,7 @@ export class RetirementService { private readonly keypairService: StellarKeypairService, private readonly configService: ConfigService, @Inject(RETIREMENT_REPOSITORY) private readonly retirementRepo: IRetirementRepository, + @Inject(EVENT_EMITTER) private readonly eventEmitter: IEventEmitter, ) { this.retirementContractId = this.configService.get( 'RETIREMENT_CONTRACT_ID', @@ -55,9 +76,19 @@ export class RetirementService { } /** - * Retire a carbon credit on-chain, then generate a PDF certificate and - * pin it to IPFS via Pinata. Returns both the on-chain retirement ID and - * the IPFS hash of the certificate. + * Retire a carbon credit on-chain and persist the retirement record + * to the off-chain index. + * + * ## Event ordering guarantee + * The `CreditRetired` application event is emitted **only after** the + * retirement record has been successfully written to the repository. + * This prevents off-chain indexers from recording a retirement that does + * not yet exist in storage if the write were to fail. + * + * Sequence: + * 1. Invoke the on-chain `retire` contract function. + * 2. Persist the `RetirementEntity` to the repository. + * 3. Emit the `CreditRetired` application event. */ async retire( dto: RetireDto, @@ -91,7 +122,10 @@ export class RetirementService { ).toString('hex') : 'unknown'; - // Persist to off-chain index + // ── Step 1: Persist to off-chain index ─────────────────────────────────── + // The record MUST be written before the CreditRetired event is emitted. + // If this write throws, the event is never emitted and the caller receives + // an error — keeping on-chain and off-chain state consistent. const entity = new RetirementEntity(); entity.id = retirementId; entity.creditId = dto.creditId; @@ -102,8 +136,99 @@ export class RetirementService { entity.txHash = ''; await this.retirementRepo.save(entity); - return { retirementId }; + // ── Step 2: Emit CreditRetired event ───────────────────────────────────── + // Only reached after a successful save, so the record is guaranteed to + // exist in storage when any listener handles this event. + const event: CreditRetiredEvent = { + retirementId, + creditId: dto.creditId, + buyer: dto.buyerPublicKey, + tonnesRetired: dto.tonnes, + retiredAt: entity.retiredAt, + }; + this.eventEmitter.emit('CreditRetired', event); + + return { retirementId, certificateIpfsHash: '' }; + } + + async getRetirement(retirementId: string): Promise { + // Try off-chain index first + const cached = await this.retirementRepo.findById(retirementId); + if (cached) return this.entityToRecord(cached); + + // Fall back to on-chain read + const args = [ + nativeToScVal(Buffer.from(retirementId, 'hex'), { type: 'bytes' }), + ]; + const retval = await this.stellarService.readContract( + this.retirementContractId, + 'get_retirement', + args, + ); + if (!retval) + throw new NotFoundException(`Retirement ${retirementId} not found`); + + const n = scValToNative(retval); + return { + id: retirementId, + credit_id: Buffer.from(n.credit_id as Uint8Array).toString('hex'), + buyer: String(n.buyer), + tonnes_retired: String(n.tonnes_retired), + reason: String(n.reason), + retired_at: Number(n.retired_at), + tx_hash: '', + }; + } + + async listRetirements(page = 1, limit = 20): Promise> { + const result = await this.retirementRepo.findAll(page, limit); + return { ...result, data: result.data.map((e) => this.entityToRecord(e)) }; + } + + async getRetirementsByAccount(account: string, page = 1, limit = 20): Promise> { + const result = await this.retirementRepo.findByBuyer(account, page, limit); + return { ...result, data: result.data.map((e) => this.entityToRecord(e)) }; + } + + private entityToRecord(e: RetirementEntity): RetirementRecord { + return { + id: e.id, + credit_id: e.creditId, + buyer: e.buyer, + tonnes_retired: e.tonnesRetired, + reason: e.reason, + retired_at: e.retiredAt, + tx_hash: e.txHash, + }; + } + + async verifyCertificate( + certificateId: string, + ): Promise { + try { + this.logger.log(`Verifying certificate: ${certificateId}`); + const retirement = await this.getRetirement(certificateId); + + return { + id: retirement.id, + credit_id: retirement.credit_id, + buyer: retirement.buyer, + tonnes_retired: retirement.tonnes_retired, + reason: retirement.reason, + retired_at: retirement.retired_at, + tx_hash: retirement.tx_hash || '', + verified: true, + }; + } catch (error: unknown) { + this.logger.error( + `Failed to verify certificate ${certificateId}: ${(error as Error).message}`, + ); + throw new NotFoundException( + `Certificate ${certificateId} not found or cannot be verified`, + ); + } } +} async getRetirement(retirementId: string): Promise { // Try off-chain index first