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
75 changes: 73 additions & 2 deletions api/src/credits/credit.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
});
});
11 changes: 11 additions & 0 deletions api/src/credits/credit.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { CreditEntity } from './credit.entity';
import { CreditStatus } from '../shared';

export interface PageResult<T> {
data: T[];
Expand All @@ -13,6 +14,11 @@ export interface ICreditRepository {
findById(id: string): Promise<CreditEntity | undefined>;
findByProject(projectId: string, page: number, limit: number): Promise<PageResult<CreditEntity>>;
findAll(page: number, limit: number): Promise<PageResult<CreditEntity>>;
/**
* 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<PageResult<CreditEntity>>;
}

export const CREDIT_REPOSITORY = 'CREDIT_REPOSITORY';
Expand Down Expand Up @@ -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<PageResult<CreditEntity>> {
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<CreditEntity> {
const offset = (page - 1) * limit;
return {
Expand Down
22 changes: 9 additions & 13 deletions api/src/credits/credits.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -203,31 +206,25 @@ 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(),
);
}

if (filter.vintageYear) {
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);
Expand All @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion api/src/retirement/retirement.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
})
Expand Down
159 changes: 159 additions & 0 deletions api/src/retirement/retirement.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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>(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');
});
});
Loading
Loading