From 841200e394bcd921dad4301fecd33a0dbebe73e7 Mon Sep 17 00:00:00 2001 From: Marvy247 Date: Sun, 30 Aug 2026 20:39:04 +0100 Subject: [PATCH] test: enhance settlement and auth JWT integration tests - Add edge case tests for settlement: non-existent invoice, zero/negative proceeds, wrong invoice status, no confirmed investments - Add ServiceError code and statusCode verification for settlement failures - Add pro-rata distribution edge cases: three-way split, excess/deficit proceeds, result structure validation - Add logging verification for lifecycle transitions and settlement completion on both success and failure paths - Add malformed token tests: random strings, two-part tokens, empty strings, invalid base64 - Add algorithm attack tests: none algorithm, wrong HS256 secrets, alg-switch attacks - Add missing claims tests: no sub, empty sub, non-existent user, non-string sub - Add Authorization header edge cases: lowercase bearer, no prefix, wrong scheme, extra whitespace - Add error response structure verification for all failure scenarios - Add token timing edge cases: future nbf, long-lived tokens Closes #350 Closes #351 Closes #352 Closes #353 --- tests/integration/auth-jwt-validation.test.ts | 538 ++++++++++++++++++ .../settlement.integration.test.ts | 485 ++++++++++++++++ 2 files changed, 1023 insertions(+) diff --git a/tests/integration/auth-jwt-validation.test.ts b/tests/integration/auth-jwt-validation.test.ts index 51e2b74..995a307 100644 --- a/tests/integration/auth-jwt-validation.test.ts +++ b/tests/integration/auth-jwt-validation.test.ts @@ -200,3 +200,541 @@ describe("JWT authentication validation", () => { }); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// JWT validation: malformed tokens +// ═══════════════════════════════════════════════════════════════════════════ + +describe("JWT validation: malformed tokens", () => { + it("rejects a completely random non-JWT string", async () => { + const app = createTestApp(); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", "Bearer not-a-jwt-at-all") + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("rejects a token with only two parts (missing signature)", async () => { + const app = createTestApp(); + + // A valid JWT has three base64url parts separated by dots. + // Create one with only header.payload (no signature). + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ sub: "GTESTADDRESS", stellarAddress: "GTESTADDRESS" }), + ).toString("base64url"); + const twoPartToken = `${header}.${payload}`; + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${twoPartToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("rejects an empty string as token", async () => { + const app = createTestApp(); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", "Bearer ") + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Authorization token is required.", + }, + }); + }); + + it("rejects a token with invalid base64url encoding in payload", async () => { + const app = createTestApp(); + + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + // Use invalid base64 characters + const invalidPayload = "!!!invalid-base64!!!"; + const badToken = `${header}.${invalidPayload}.signature`; + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${badToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// JWT validation: algorithm and signing attacks +// ═══════════════════════════════════════════════════════════════════════════ + +describe("JWT validation: algorithm and signing attacks", () => { + it("rejects a token signed with 'none' algorithm", async () => { + const app = createTestApp(); + + // jwt.sign with algorithm: 'none' produces an unsigned token + const unsignedToken = jwt.sign( + { + sub: "GNONEALGADDRESS", + stellarAddress: "GNONEALGADDRESS", + userId: crypto.randomUUID(), + }, + "", + { algorithm: "none", expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${unsignedToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("rejects a token signed with a different HS256 secret", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "GDIFFERENT_SECRET_ADDR", + stellarAddress: "GDIFFERENT_SECRET_ADDR", + userId: crypto.randomUUID(), + }, + "completely-different-secret", + { algorithm: "HS256", expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("rejects a token where the header is tampered to use HS256 but was originally signed with a different key", async () => { + const app = createTestApp(); + + // Sign with one secret, then manually change the alg in the header + // and re-encode to simulate an alg-switch attack + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + sub: "GTAMPEREDADDR", + stellarAddress: "GTAMPEREDADDR", + userId: crypto.randomUUID(), + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 900, + }), + ).toString("base64url"); + + // Sign the tampered header.payload with a wrong secret + const signature = require("crypto") + .createHmac("sha256", "wrong-secret") + .update(`${header}.${payload}`) + .digest("base64url"); + + const tamperedToken = `${header}.${payload}.${signature}`; + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${tamperedToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// JWT validation: missing or invalid claims +// ═══════════════════════════════════════════════════════════════════════════ + +describe("JWT validation: missing or invalid claims", () => { + it("rejects a token with no sub claim", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + stellarAddress: "GNOSUBCLAIM", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("rejects a token with an empty sub claim", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "", + stellarAddress: "", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("rejects a valid JWT for a user that does not exist in the repository", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "GNONEXISTENTUSERADDRESS", + stellarAddress: "GNONEXISTENTUSERADDRESS", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: expect.stringContaining("Invalid or expired token."), + }, + }); + }); + + it("rejects a token with sub set to a non-string value", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: 12345, + stellarAddress: "GNOTASTRING", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// JWT validation: Authorization header edge cases +// ═══════════════════════════════════════════════════════════════════════════ + +describe("JWT validation: Authorization header edge cases", () => { + it("rejects request with lowercase 'bearer' prefix", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "GLOWERCASEBEARER", + stellarAddress: "GLOWERCASEBEARER", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `bearer ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Authorization token is required.", + }, + }); + }); + + it("rejects request with raw token (no Bearer prefix)", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "GNOBEARERPREFIX", + stellarAddress: "GNOBEARERPREFIX", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", token) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Authorization token is required.", + }, + }); + }); + + it("rejects request with 'Token' scheme instead of 'Bearer'", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "GTOKENSCHEME", + stellarAddress: "GTOKENSCHEME", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Token ${token}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Authorization token is required.", + }, + }); + }); + + it("rejects request with extra whitespace around a valid token", async () => { + const app = createTestApp(); + + const token = jwt.sign( + { + sub: "GEXTRAWHITESPACE", + stellarAddress: "GEXTRAWHITESPACE", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m" }, + ); + + // The server should trim the token before verification + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${token} `) + .expect(401); + + // Depending on implementation, extra whitespace may cause signature mismatch + expect(response.body).toMatchObject({ + success: false, + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// JWT validation: error response structure +// ═══════════════════════════════════════════════════════════════════════════ + +describe("JWT validation: error response structure", () => { + it("returns consistent error envelope on forged token", async () => { + const app = createTestApp(); + + const forgedToken = jwt.sign( + { + sub: "GSTRUCTTEST1", + stellarAddress: "GSTRUCTTEST1", + }, + "wrong-secret", + { expiresIn: "15m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${forgedToken}`) + .expect(401); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("error"); + expect(response.body.error).toHaveProperty("message"); + expect(typeof response.body.error.message).toBe("string"); + }); + + it("returns consistent error envelope on expired token", async () => { + const app = createTestApp(); + + const expiredToken = jwt.sign( + { + sub: "GSTRUCTTEST2", + stellarAddress: "GSTRUCTTEST2", + }, + VALID_JWT_SECRET, + { expiresIn: "-10m" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${expiredToken}`) + .expect(401); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("error"); + expect(response.body.error).toHaveProperty("message"); + }); + + it("returns consistent error envelope when no Authorization header is sent", async () => { + const app = createTestApp(); + + const response = await request(app).get("/api/v1/auth/me").expect(401); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("error"); + expect(response.body.error).toHaveProperty("message"); + }); + + it("returns 401 (not 403 or 500) for all invalid token scenarios", async () => { + const app = createTestApp(); + + const scenarios = [ + { label: "forged token", token: jwt.sign({ sub: "GX" }, "wrong", { expiresIn: "1m" }) }, + { label: "expired token", token: jwt.sign({ sub: "GX" }, VALID_JWT_SECRET, { expiresIn: "-1m" }) }, + { label: "random string", token: "not-a-jwt" }, + ]; + + for (const scenario of scenarios) { + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${scenario.token}`) + .expect(401); + + expect(response.body.success).toBe(false); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// JWT validation: token with future nbf (not yet valid) +// ═══════════════════════════════════════════════════════════════════════════ + +describe("JWT validation: token timing edge cases", () => { + it("rejects a token with nbf set far in the future", async () => { + const app = createTestApp(); + + const futureToken = jwt.sign( + { + sub: "GFUTURETOKEN", + stellarAddress: "GFUTURETOKEN", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "15m", notBefore: "1h" }, + ); + + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${futureToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Invalid or expired token.", + }, + }); + }); + + it("accepts a token with iat set to now and exp set to far future", async () => { + const app = createTestApp(); + + // This token is valid for a very long time + const validToken = jwt.sign( + { + sub: "GLONGVALIDTOKEN", + stellarAddress: "GLONGVALIDTOKEN", + userId: crypto.randomUUID(), + }, + VALID_JWT_SECRET, + { expiresIn: "365d" }, + ); + + // The token itself is valid, but the user doesn't exist, so we expect 401 + // with a message about invalid/expired token (not about missing token) + const response = await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${validToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + success: false, + error: { + message: expect.stringContaining("Invalid or expired token."), + }, + }); + }); +}); diff --git a/tests/integration/settlement.integration.test.ts b/tests/integration/settlement.integration.test.ts index e4677da..92f5e57 100644 --- a/tests/integration/settlement.integration.test.ts +++ b/tests/integration/settlement.integration.test.ts @@ -6,6 +6,7 @@ import { Invoice } from "../../src/models/Invoice.model"; import { Investment } from "../../src/models/Investment.model"; import { InvoiceStatus, InvestmentStatus } from "../../src/types/enums"; import { logger } from "../../src/observability/logger"; +import { ServiceError } from "../../src/utils/service-error"; /** * Minimal in-memory TypeORM stand-in shared by InvestmentService and @@ -107,6 +108,41 @@ function createInvoice(overrides: Partial = {}): Invoice { } as Invoice; } +// ── Helper: mark investment as confirmed in the fake store ────────────────── + +function confirmInvestment(investments: Map, investment: Investment) { + const stored = investments.get(investment.id)!; + stored.status = InvestmentStatus.CONFIRMED; + investments.set(investment.id, stored); +} + +// ── Helper: fund an invoice fully with N investors ────────────────────────── + +async function fullyFundInvoice( + dataSource: DataSource, + investments: Map, + invoice: Invoice, + shares: Array<{ amount: string; wallet: string }>, +) { + const investmentService = new InvestmentService(dataSource); + const created = []; + for (const share of shares) { + const inv = await investmentService.createInvestment({ + invoiceId: invoice.id, + investorId: crypto.randomUUID(), + investmentAmount: share.amount, + investorWallet: share.wallet, + }); + confirmInvestment(investments, inv); + created.push(inv); + } + return created; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Settlement integration: rejecting settlement of non-fully-funded invoices +// ═══════════════════════════════════════════════════════════════════════════ + describe("Settlement integration: rejecting settlement of non-fully-funded invoices (#88)", () => { it("should reject settlement of a published invoice (no investments)", async () => { const invoice = createInvoice({ status: InvoiceStatus.PUBLISHED }); @@ -199,6 +235,10 @@ describe("Settlement integration: rejecting settlement of non-fully-funded invoi }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// Settlement integration: funding multiple investors then settling +// ═══════════════════════════════════════════════════════════════════════════ + describe("Settlement integration: funding multiple investors then settling", () => { afterEach(() => { jest.restoreAllMocks(); @@ -335,6 +375,10 @@ describe("Settlement integration: funding multiple investors then settling", () }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// Settlement integration: single investor 100% share +// ═══════════════════════════════════════════════════════════════════════════ + describe("Settlement integration: single investor 100% share", () => { it("returns the full proceeds to the only investor", async () => { const invoice = createInvoice(); @@ -369,3 +413,444 @@ describe("Settlement integration: single investor 100% share", () => { expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.SETTLED); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// Settlement integration: edge cases and error handling +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Settlement integration: edge cases and input validation", () => { + it("rejects settlement of a non-existent invoice with 404", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: crypto.randomUUID(), + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Invoice not found/); + }); + + it("rejects settlement with zero proceeds", async () => { + const invoice = createInvoice({ status: InvoiceStatus.FUNDED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "0.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Settlement proceeds must be greater than zero/); + }); + + it("rejects settlement with negative proceeds", async () => { + const invoice = createInvoice({ status: InvoiceStatus.FUNDED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "-100.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Settlement proceeds must be greater than zero/); + }); + + it("rejects settlement of an already settled invoice", async () => { + const invoice = createInvoice({ status: InvoiceStatus.SETTLED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Cannot settle an invoice with status settled/); + }); + + it("rejects settlement of a cancelled invoice", async () => { + const invoice = createInvoice({ status: InvoiceStatus.CANCELLED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Cannot settle an invoice with status cancelled/); + }); + + it("rejects settlement of a draft invoice", async () => { + const invoice = createInvoice({ status: InvoiceStatus.DRAFT }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Cannot settle an invoice with status draft/); + }); + + it("rejects settlement when invoice is funded but has no confirmed investments", async () => { + const invoice = createInvoice({ status: InvoiceStatus.FUNDED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Invoice has no confirmed investments to settle/); + }); + + it("throws ServiceError with INVALID_PROCEEDS code for zero proceeds", async () => { + const invoice = createInvoice({ status: InvoiceStatus.FUNDED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + try { + await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "0.0000", + actorWallet: "GADMIN", + }); + fail("Expected ServiceError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).code).toBe("INVALID_PROCEEDS"); + expect((error as ServiceError).statusCode).toBe(400); + } + }); + + it("throws ServiceError with INVOICE_NOT_FOUND code for missing invoice", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + try { + await settlementService.settleInvoice({ + invoiceId: crypto.randomUUID(), + proceeds: "6000.0000", + actorWallet: "GADMIN", + }); + fail("Expected ServiceError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).code).toBe("INVOICE_NOT_FOUND"); + expect((error as ServiceError).statusCode).toBe(404); + } + }); + + it("throws ServiceError with INVALID_INVOICE_STATUS code for wrong status", async () => { + const invoice = createInvoice({ status: InvoiceStatus.PUBLISHED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + try { + await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }); + fail("Expected ServiceError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).code).toBe("INVALID_INVOICE_STATUS"); + } + }); + + it("throws ServiceError with NO_CONFIRMED_INVESTMENTS code when no investments confirmed", async () => { + const invoice = createInvoice({ status: InvoiceStatus.FUNDED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + try { + await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }); + fail("Expected ServiceError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceError); + expect((error as ServiceError).code).toBe("NO_CONFIRMED_INVESTMENTS"); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Settlement integration: pro-rata distribution edge cases +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Settlement integration: pro-rata distribution edge cases", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("handles uneven three-way split correctly", async () => { + const invoice = createInvoice({ amount: "10000.0000", netAmount: "10000.0000" }); + const { dataSource, invoices, investments } = createFakeDataSource(invoice); + + const shares = [ + { amount: "5000.0000", wallet: "GINVESTOR1" + "A".repeat(54) }, + { amount: "3000.0000", wallet: "GINVESTOR2" + "B".repeat(54) }, + { amount: "2000.0000", wallet: "GINVESTOR3" + "C".repeat(54) }, + ]; + + const createdInvestments = await fullyFundInvoice(dataSource, investments, invoice, shares); + expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.FUNDED); + + const settlementService = new SettlementService(dataSource); + const result = await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "10000.0000", + actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + + expect(result.status).toBe(InvoiceStatus.SETTLED); + expect(result.settlements).toHaveLength(3); + + const returnByInvestor = new Map( + result.settlements.map((s) => [s.investorId, Number(s.actualReturn)]), + ); + + // 50% -> 5000, 30% -> 3000, 20% -> 2000 + const investorIds = createdInvestments.map((inv) => inv.investorId); + expect(returnByInvestor.get(investorIds[0])).toBe(5000); + expect(returnByInvestor.get(investorIds[1])).toBe(3000); + expect(returnByInvestor.get(investorIds[2])).toBe(2000); + + const totalReturn = [...returnByInvestor.values()].reduce((a, b) => a + b, 0); + expect(totalReturn).toBeCloseTo(10000, 4); + }); + + it("distributes correctly when proceeds exceed the funded amount", async () => { + const invoice = createInvoice({ amount: "6000.0000", netAmount: "6000.0000" }); + const { dataSource, invoices, investments } = createFakeDataSource(invoice); + + const shares = [ + { amount: "4000.0000", wallet: "GINVESTOR_A" + "X".repeat(53) }, + { amount: "2000.0000", wallet: "GINVESTOR_B" + "Y".repeat(53) }, + ]; + + const createdInvestments = await fullyFundInvoice(dataSource, investments, invoice, shares); + expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.FUNDED); + + const settlementService = new SettlementService(dataSource); + const result = await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "9000.0000", + actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + + const returnByInvestor = new Map( + result.settlements.map((s) => [s.investorId, Number(s.actualReturn)]), + ); + + // 4000/6000 * 9000 = 6000, 2000/6000 * 9000 = 3000 + const investorIds = createdInvestments.map((inv) => inv.investorId); + expect(returnByInvestor.get(investorIds[0])).toBe(6000); + expect(returnByInvestor.get(investorIds[1])).toBe(3000); + + const totalReturn = [...returnByInvestor.values()].reduce((a, b) => a + b, 0); + expect(totalReturn).toBeCloseTo(9000, 4); + }); + + it("distributes correctly when proceeds are less than the funded amount", async () => { + const invoice = createInvoice({ amount: "6000.0000", netAmount: "6000.0000" }); + const { dataSource, invoices, investments } = createFakeDataSource(invoice); + + const shares = [ + { amount: "3000.0000", wallet: "GINVESTOR_LOW1" + "A".repeat(50) }, + { amount: "3000.0000", wallet: "GINVESTOR_LOW2" + "B".repeat(50) }, + ]; + + const createdInvestments = await fullyFundInvoice(dataSource, investments, invoice, shares); + expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.FUNDED); + + const settlementService = new SettlementService(dataSource); + const result = await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "2000.0000", + actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + + const returnByInvestor = new Map( + result.settlements.map((s) => [s.investorId, Number(s.actualReturn)]), + ); + + // Equal shares: each gets 1000 + const investorIds = createdInvestments.map((inv) => inv.investorId); + expect(returnByInvestor.get(investorIds[0])).toBe(1000); + expect(returnByInvestor.get(investorIds[1])).toBe(1000); + }); + + it("preserves settlement result structure with all required fields", async () => { + const invoice = createInvoice(); + const { dataSource, invoices, investments } = createFakeDataSource(invoice); + + const shares = [ + { amount: "6000.0000", wallet: "GINVESTOR_FULL" + "Z".repeat(50) }, + ]; + + const createdInvestments = await fullyFundInvoice(dataSource, investments, invoice, shares); + expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.FUNDED); + + const settlementService = new SettlementService(dataSource); + const result = await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "7200.0000", + actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + + expect(result).toHaveProperty("invoiceId", invoice.id); + expect(result).toHaveProperty("status", InvoiceStatus.SETTLED); + expect(result).toHaveProperty("proceeds", "7200.0000"); + expect(result).toHaveProperty("settlements"); + expect(Array.isArray(result.settlements)).toBe(true); + + const settlement = result.settlements[0]; + expect(settlement).toHaveProperty("investmentId"); + expect(settlement).toHaveProperty("investorId"); + expect(settlement).toHaveProperty("investmentAmount", "6000.0000"); + expect(settlement).toHaveProperty("actualReturn", "7200.0000"); + }); + + it("logs lifecycle transition from funded to settled on success", async () => { + const infoSpy = jest.spyOn(logger, "info"); + + const invoice = createInvoice(); + const { dataSource, investments } = createFakeDataSource(invoice); + + await fullyFundInvoice(dataSource, investments, invoice, [ + { amount: "6000.0000", wallet: "GINVESTOR_LOG" + "L".repeat(50) }, + ]); + + const settlementService = new SettlementService(dataSource); + await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + + const lifecycleCall = infoSpy.mock.calls.find( + ([message]) => message === "Invoice lifecycle state transition.", + ); + expect(lifecycleCall).toBeDefined(); + + const metadata = lifecycleCall?.[1] as Record; + expect(metadata.invoice_id).toBe(invoice.id); + expect(metadata.from_state).toBe(InvoiceStatus.FUNDED); + expect(metadata.to_state).toBe(InvoiceStatus.SETTLED); + expect(metadata.reason).toBe("admin_settled"); + expect(metadata.transitioned_at).toEqual(expect.any(String)); + }); + + it("does not log lifecycle transition when settlement fails due to wrong status", async () => { + const infoSpy = jest.spyOn(logger, "info"); + + const invoice = createInvoice({ status: InvoiceStatus.PUBLISHED }); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(); + + const lifecycleCall = infoSpy.mock.calls.find( + ([message]) => message === "Invoice lifecycle state transition.", + ); + expect(lifecycleCall).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Settlement integration: logging verification +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Settlement integration: logging verification", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("logs both lifecycle transition and settlement completion on successful settlement", async () => { + const infoSpy = jest.spyOn(logger, "info"); + + const invoice = createInvoice(); + const { dataSource, investments } = createFakeDataSource(invoice); + + await fullyFundInvoice(dataSource, investments, invoice, [ + { amount: "4000.0000", wallet: "GINVESTOR_LOG1" + "M".repeat(49) }, + { amount: "2000.0000", wallet: "GINVESTOR_LOG2" + "N".repeat(49) }, + ]); + + const settlementService = new SettlementService(dataSource); + await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6600.0000", + actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }); + + const lifecycleCall = infoSpy.mock.calls.find( + ([msg]) => msg === "Invoice lifecycle state transition.", + ); + const completionCall = infoSpy.mock.calls.find( + ([msg]) => msg === "Settlement flow completed.", + ); + + expect(lifecycleCall).toBeDefined(); + expect(completionCall).toBeDefined(); + + const lifecycleMeta = lifecycleCall?.[1] as Record; + expect(lifecycleMeta.from_state).toBe(InvoiceStatus.FUNDED); + expect(lifecycleMeta.to_state).toBe(InvoiceStatus.SETTLED); + + const completionMeta = completionCall?.[1] as Record; + expect(completionMeta.investor_count).toBe(2); + expect(completionMeta.total_proceeds).toBe("6600.0000000"); + }); + + it("does not log settlement-related info logs when invoice not found", async () => { + const infoSpy = jest.spyOn(logger, "info"); + + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice); + const settlementService = new SettlementService(dataSource); + + await expect( + settlementService.settleInvoice({ + invoiceId: crypto.randomUUID(), + proceeds: "6000.0000", + actorWallet: "GADMIN", + }), + ).rejects.toThrow(/Invoice not found/); + + const lifecycleCall = infoSpy.mock.calls.find( + ([msg]) => msg === "Invoice lifecycle state transition.", + ); + const completionCall = infoSpy.mock.calls.find( + ([msg]) => msg === "Settlement flow completed.", + ); + + expect(lifecycleCall).toBeUndefined(); + expect(completionCall).toBeUndefined(); + }); +});