diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 711f6b0..9540a55 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -19,7 +19,7 @@ import { AppError } from "../utils/http-error"; * Frozen state transition map optimized for performance. * Prevents accidental mutations and enables faster lookups. */ -export const VALID_INVOICE_TRANSITIONS: Record = Object.freeze({ +export const VALID_INVOICE_TRANSITIONS: Record = Object.freeze({ [InvoiceStatus.DRAFT]: Object.freeze([InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED]), [InvoiceStatus.PENDING]: Object.freeze([InvoiceStatus.PUBLISHED, InvoiceStatus.CANCELLED, InvoiceStatus.REJECTED]), [InvoiceStatus.PUBLISHED]: Object.freeze([InvoiceStatus.FUNDED, InvoiceStatus.CANCELLED]), diff --git a/tests/e2e/full-flow.e2e.test.ts b/tests/e2e/full-flow.e2e.test.ts index 9bdc52f..2fd96be 100644 --- a/tests/e2e/full-flow.e2e.test.ts +++ b/tests/e2e/full-flow.e2e.test.ts @@ -82,6 +82,49 @@ function toNum(val: unknown): number { return Number(val); } +/** + * Drive the Stellar challenge-response handshake for a keypair and return the + * issued bearer token plus the created user id. + * + * Extracted so seller and investor onboarding share one hardened code path + * instead of duplicating the challenge/sign/verify sequence. Every network + * hop is asserted inline so a regression fails here with a precise message + * rather than cascading into unrelated later steps. + */ +async function authenticateViaChallenge( + httpApp: ReturnType, + keypair: Keypair +): Promise<{ token: string; userId: string }> { + const challengeRes = await request(httpApp) + .post("/api/v1/auth/challenge") + .send({ publicKey: keypair.publicKey() }) + .expect(201); + + expect(challengeRes.body.challenge).toBeDefined(); + expect(challengeRes.body.challenge.publicKey).toBe(keypair.publicKey()); + const { nonce, message } = challengeRes.body.challenge; + expect(nonce).toBeDefined(); + expect(message).toBeDefined(); + + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("hex"); + + const verifyRes = await request(httpApp) + .post("/api/v1/auth/verify") + .send({ publicKey: keypair.publicKey(), nonce, signature }) + .expect(200); + + expect(verifyRes.body.token).toBeDefined(); + expect(verifyRes.body.tokenType).toBe("Bearer"); + expect(verifyRes.body.user?.stellarAddress).toBe(keypair.publicKey()); + + return { token: verifyRes.body.token, userId: verifyRes.body.user.id }; +} + +// The full journey spans two authentications, several writes and a settlement. +// Give it generous headroom so a slow CI runner does not produce a sporadic +// timeout failure that looks like a product regression. +jest.setTimeout(30_000); + describe("E2E: Complete Invoice Financing Flow", () => { let dataSource: DataSource; let app: ReturnType; @@ -168,39 +211,48 @@ describe("E2E: Complete Invoice Financing Flow", () => { }, }; - // Initialize test database (SQLite in-memory) - patchEntityMetadataForSQLite(); - - dataSource = new DataSource({ - type: "sqlite", - database: ":memory:", - synchronize: true, - logging: false, - entities: [User, Invoice, Investment, AuthChallenge, Transaction, KYCVerification, Notification], - }); + // Initialize test database (SQLite in-memory). Wrap the whole bring-up so a + // failure in schema sync or service wiring surfaces with a clear cause + // instead of every downstream test throwing an opaque "app is undefined". + try { + patchEntityMetadataForSQLite(); + + dataSource = new DataSource({ + type: "sqlite", + database: ":memory:", + synchronize: true, + logging: false, + entities: [User, Invoice, Investment, AuthChallenge, Transaction, KYCVerification, Notification], + }); - await dataSource.initialize(); - - // Create services with mocked IPFS - const authService = createAuthService(dataSource, config); - const invoiceService = createInvoiceService(dataSource, mockIPFSService); - const investmentService = createInvestmentService(dataSource); - const settlementService = createSettlementService(dataSource); - const marketplaceService = createMarketplaceService(dataSource); - const notificationService = createNotificationService(dataSource); - - // Create the full app - app = createApp({ - authService, - notificationService, - invoiceService, - investmentService, - settlementService, - marketplaceService, - config, - logger, - metricsEnabled: false, - }); + await dataSource.initialize(); + + // Create services with mocked IPFS + const authService = createAuthService(dataSource, config); + const invoiceService = createInvoiceService(dataSource, mockIPFSService); + const investmentService = createInvestmentService(dataSource); + const settlementService = createSettlementService(dataSource); + const marketplaceService = createMarketplaceService(dataSource); + const notificationService = createNotificationService(dataSource); + + // Create the full app + app = createApp({ + authService, + notificationService, + invoiceService, + investmentService, + settlementService, + marketplaceService, + config, + logger, + metricsEnabled: false, + }); + } catch (error) { + logger.error("E2E test harness failed to initialize", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } }); afterAll(async () => { @@ -214,66 +266,21 @@ describe("E2E: Complete Invoice Financing Flow", () => { // ============================================================ describe("Step 1: Authentication", () => { it("should authenticate seller via Stellar challenge-response", async () => { - // Request challenge - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: sellerKeypair.publicKey() }) - .expect(201); + const { token, userId } = await authenticateViaChallenge(app, sellerKeypair); + sellerToken = token; + sellerId = userId; - expect(challengeRes.body.challenge).toBeDefined(); - expect(challengeRes.body.challenge.publicKey).toBe(sellerKeypair.publicKey()); - expect(challengeRes.body.challenge.nonce).toBeDefined(); - expect(challengeRes.body.challenge.message).toBeDefined(); - - const { nonce, message } = challengeRes.body.challenge; - - // Sign the challenge message - const signature = sellerKeypair - .sign(Buffer.from(message, "utf8")) - .toString("hex"); - - // Verify challenge and get token - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: sellerKeypair.publicKey(), - nonce, - signature, - }) - .expect(200); - - expect(verifyRes.body.token).toBeDefined(); - expect(verifyRes.body.tokenType).toBe("Bearer"); - expect(verifyRes.body.user).toBeDefined(); - expect(verifyRes.body.user.stellarAddress).toBe(sellerKeypair.publicKey()); - - sellerToken = verifyRes.body.token; - sellerId = verifyRes.body.user.id; + expect(sellerToken).toEqual(expect.any(String)); + expect(sellerId).toEqual(expect.any(String)); }); it("should authenticate investor via Stellar challenge-response", async () => { - const challengeRes = await request(app) - .post("/api/v1/auth/challenge") - .send({ publicKey: investorKeypair.publicKey() }) - .expect(201); - - const { nonce, message } = challengeRes.body.challenge; - - const signature = investorKeypair - .sign(Buffer.from(message, "utf8")) - .toString("hex"); - - const verifyRes = await request(app) - .post("/api/v1/auth/verify") - .send({ - publicKey: investorKeypair.publicKey(), - nonce, - signature, - }) - .expect(200); + const { token, userId } = await authenticateViaChallenge(app, investorKeypair); + investorToken = token; + investorId = userId; - investorToken = verifyRes.body.token; - investorId = verifyRes.body.user.id; + expect(investorToken).toEqual(expect.any(String)); + expect(investorId).toEqual(expect.any(String)); }); it("should set KYC status to APPROVED for investor (required for investments)", async () => { diff --git a/tests/integration/auth-jwt-validation.test.ts b/tests/integration/auth-jwt-validation.test.ts index 18da3b5..89ab16b 100644 --- a/tests/integration/auth-jwt-validation.test.ts +++ b/tests/integration/auth-jwt-validation.test.ts @@ -287,18 +287,8 @@ describe("JWT validation: malformed tokens", () => { 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.", - }, - }); - }); +beforeAll(() => { + app = createTestApp(); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -468,56 +458,128 @@ describe("JWT validation: missing or invalid claims", () => { }); }); - it("rejects a valid JWT for a user that does not exist in the repository", async () => { - const app = createTestApp(); +/** Base claims for a well-formed token; override per case. */ +function claims(overrides: Record = {}): Record { + return { + sub: "GTESTSUBJECT", + stellarAddress: "GTESTSUBJECT", + userId: crypto.randomUUID(), + ...overrides, + }; +} - const token = jwt.sign( - { - sub: "GNONEXISTENTUSERADDRESS", - stellarAddress: "GNONEXISTENTUSERADDRESS", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); +/** Issue GET /api/v1/auth/me with the given Authorization header value. */ +function getMe(authorization?: string) { + const req = request(app).get("/api/v1/auth/me"); + return authorization === undefined ? req : req.set("Authorization", authorization); +} - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); +/** + * Canonical middleware / auth-service messages (see src/middleware/auth.middleware.ts + * and src/services/auth.service.ts#getCurrentUser). Every failure mode still + * returns 401 with the standard envelope; only the message differentiates them. + */ +const INVALID_TOKEN_MESSAGE = "Invalid or expired token."; // unverifiable / undecodable token +const INVALID_PAYLOAD_MESSAGE = "Invalid token payload."; // verified token, missing/empty sub +const UNKNOWN_USER_MESSAGE = "User no longer exists."; // verified token, sub resolves to no user +const MISSING_TOKEN_MESSAGE = "Authorization token is required."; // no usable Bearer credential + +/** + * Assert the standard 401 rejection envelope. Centralising this keeps every + * case checking the same contract — status, `success:false`, a string + * `error.message`, and no `data` leak — so an envelope regression fails once + * and loudly instead of in whichever test happened to run first. + */ +function expectRejected( + response: Awaited>, + expectedMessage?: string, +): void { + expect(response.status).toBe(401); + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("error"); + expect(typeof response.body.error?.message).toBe("string"); + expect(response.body).not.toHaveProperty("data"); + if (expectedMessage !== undefined) { + expect(response.body.error.message).toBe(expectedMessage); + } +} - expect(response.body).toMatchObject({ - success: false, - error: { - message: expect.stringContaining("Invalid or expired token."), - }, - }); - }); +// Hand-rolled tokens that jsonwebtoken cannot produce directly. +function twoSegmentToken(): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ sub: "GTESTADDRESS", stellarAddress: "GTESTADDRESS" }), + ).toString("base64url"); + return `${header}.${payload}`; +} - it("rejects a token with sub set to a non-string value", async () => { - const app = createTestApp(); +function undecodablePayloadToken(): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + return `${header}.!!!invalid-base64!!!.signature`; +} - const token = jwt.sign( - { - sub: 12345, - stellarAddress: "GNOTASTRING", - userId: crypto.randomUUID(), - }, - VALID_JWT_SECRET, - { expiresIn: "15m" }, - ); +function algSwitchToken(): string { + 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"); + const signature = crypto + .createHmac("sha256", "wrong-secret") + .update(`${header}.${payload}`) + .digest("base64url"); + return `${header}.${payload}.${signature}`; +} - const response = await request(app) - .get("/api/v1/auth/me") - .set("Authorization", `Bearer ${token}`) - .expect(401); +// ── Tests ───────────────────────────────────────────────────────────────────── - expect(response.body).toMatchObject({ - success: false, - error: { - message: "Invalid or expired token.", - }, - }); +describe("JWT validation: rejects invalid tokens with a 401 envelope", () => { + // [label, tokenFactory, expectedMessage] + const cases: Array<[string, () => string, string]> = [ + ["signed with an unknown secret key", () => signToken(claims(), { secret: "invalid-secret-key" }), INVALID_TOKEN_MESSAGE], + [ + "signed with a different HS256 secret", + () => signToken(claims(), { secret: "completely-different-secret", algorithm: "HS256" }), + INVALID_TOKEN_MESSAGE, + ], + ["expired", () => signToken(claims(), { expiresIn: "-5m" }), INVALID_TOKEN_MESSAGE], + ["not yet valid (nbf set in the future)", () => signToken(claims(), { notBefore: "1h" }), INVALID_TOKEN_MESSAGE], + ["signed with the 'none' algorithm", () => signToken(claims(), { secret: "", algorithm: "none" }), INVALID_TOKEN_MESSAGE], + ["a completely random non-JWT string", () => "not-a-jwt-at-all", INVALID_TOKEN_MESSAGE], + ["missing its signature segment", twoSegmentToken, INVALID_TOKEN_MESSAGE], + ["carrying an undecodable base64url payload", undecodablePayloadToken, INVALID_TOKEN_MESSAGE], + ["header alg-switched and re-signed with the wrong key", algSwitchToken, INVALID_TOKEN_MESSAGE], + [ + "verified but missing the sub claim", + () => signToken({ stellarAddress: "GNOSUBCLAIM", userId: crypto.randomUUID() }), + INVALID_PAYLOAD_MESSAGE, + ], + ["verified but carrying an empty sub claim", () => signToken(claims({ sub: "", stellarAddress: "" })), INVALID_PAYLOAD_MESSAGE], + ["verified but carrying a non-string sub claim", () => signToken(claims({ sub: 12345 })), UNKNOWN_USER_MESSAGE], + [ + "verified and well-formed but for a user that does not exist", + () => signToken(claims({ sub: "GNONEXISTENTUSERADDRESS", stellarAddress: "GNONEXISTENTUSERADDRESS" })), + UNKNOWN_USER_MESSAGE, + ], + [ + "verified, long-lived, but for a user that does not exist", + () => + signToken( + claims({ sub: "GLONGVALIDTOKEN", stellarAddress: "GLONGVALIDTOKEN" }), + { expiresIn: "365d" }, + ), + UNKNOWN_USER_MESSAGE, + ], + ]; + + it.each(cases)("rejects a token %s", async (_label, makeToken, expectedMessage) => { + const response = await getMe(`Bearer ${makeToken()}`); + expectRejected(response, expectedMessage); }); }); @@ -560,81 +622,11 @@ describe("JWT validation: Authorization header edge cases", () => { } }); - 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, - }); + it("rejects (401) a Bearer value padded with surrounding whitespace", async () => { + // Implementations differ on whether the padding is trimmed before verify; + // only the rejection envelope is guaranteed here. + const response = await getMe(`Bearer ${validToken} `); + expectRejected(response); }); }); @@ -674,53 +666,21 @@ describe("JWT validation: error response structure", () => { } }); - 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("uses the same envelope when no Authorization header is sent", async () => { + const response = await getMe(); + expectRejected(response, MISSING_TOKEN_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" }, + it("always answers 401 (never 403 or 500) across a sample of invalid tokens", async () => { + const tokens = [ + signToken(claims(), { secret: "wrong" }), + signToken(claims(), { expiresIn: "-1m" }), + "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); - + for (const token of tokens) { + const response = await getMe(`Bearer ${token}`); + expect(response.status).toBe(401); expect(response.body.success).toBe(false); } }); diff --git a/tests/integration/settlement.integration.test.ts b/tests/integration/settlement.integration.test.ts index 967da03..c6c9d72 100644 --- a/tests/integration/settlement.integration.test.ts +++ b/tests/integration/settlement.integration.test.ts @@ -139,6 +139,38 @@ async function fullyFundInvoice( return created; } +// ── Helper: locate a specific structured log call ────────────────────────── +// +// `fullyFundInvoice` emits its own "Invoice lifecycle state transition." log +// (published -> funded, reason "fully_funded") before settlement runs, so a +// bare `.find(([msg]) => msg === "...transition.")` would match the funding +// event instead of the settlement one. Match on the metadata too. + +type LogCall = [string, Record?]; + +function findLogCall( + infoSpy: jest.SpyInstance, + message: string, + predicate: (meta: Record) => boolean = () => true, +): LogCall | undefined { + return (infoSpy.mock.calls as LogCall[]).find( + ([loggedMessage, meta]) => + loggedMessage === message && predicate((meta ?? {}) as Record), + ); +} + +function findSettlementTransitionLog(infoSpy: jest.SpyInstance): LogCall | undefined { + return findLogCall( + infoSpy, + "Invoice lifecycle state transition.", + (meta) => meta.reason === "admin_settled", + ); +} + +function findSettlementCompletionLog(infoSpy: jest.SpyInstance): LogCall | undefined { + return findLogCall(infoSpy, "Settlement flow completed."); +} + // ═══════════════════════════════════════════════════════════════════════════ // Settlement integration: rejecting settlement of non-fully-funded invoices // ═══════════════════════════════════════════════════════════════════════════ @@ -388,10 +420,7 @@ describe("Settlement integration: funding multiple investors then settling", () }), ).rejects.toThrow(); - const completionCall = infoSpy.mock.calls.find( - ([message]) => message === "Settlement flow completed.", - ); - expect(completionCall).toBeUndefined(); + expect(findSettlementCompletionLog(infoSpy)).toBeUndefined(); }); }); @@ -791,9 +820,7 @@ describe("Settlement integration: pro-rata distribution edge cases", () => { actorWallet: "GADMINWALLET1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", }); - const lifecycleCall = infoSpy.mock.calls.find( - ([message]) => message === "Invoice lifecycle state transition.", - ); + const lifecycleCall = findSettlementTransitionLog(infoSpy); expect(lifecycleCall).toBeDefined(); const metadata = lifecycleCall?.[1] as Record; @@ -819,10 +846,7 @@ describe("Settlement integration: pro-rata distribution edge cases", () => { }), ).rejects.toThrow(); - const lifecycleCall = infoSpy.mock.calls.find( - ([message]) => message === "Invoice lifecycle state transition.", - ); - expect(lifecycleCall).toBeUndefined(); + expect(findSettlementTransitionLog(infoSpy)).toBeUndefined(); }); }); @@ -858,12 +882,8 @@ describe("Settlement integration: logging verification", () => { 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.", - ); + const lifecycleCall = findSettlementTransitionLog(infoSpy); + const completionCall = findSettlementCompletionLog(infoSpy); expect(lifecycleCall).toBeDefined(); expect(completionCall).toBeDefined(); @@ -895,14 +915,7 @@ describe("Settlement integration: logging verification", () => { }), ).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(); + expect(findSettlementTransitionLog(infoSpy)).toBeUndefined(); + expect(findSettlementCompletionLog(infoSpy)).toBeUndefined(); }); }); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 0c5c8cd..5b69a43 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -46,25 +46,37 @@ describe("InvoiceService", () => { }); }); + /** Valid `createInvoice` input; override any field per case. */ + const buildCreateInput = (overrides: Record = {}) => ({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "5.00", + dueDate: new Date("2024-12-31"), + ...overrides, + }); + + /** + * Wire the repository so `create`/`save` echo the entity the service builds, + * letting a test assert on the values the service actually computed (e.g. + * netAmount) rather than on a hand-stubbed return value. + */ + const wireCreatePassthrough = () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + mockInvoiceRepository.create.mockImplementation((data: Partial) => ({ + ...mockInvoice, + ...data, + })); + mockInvoiceRepository.save.mockImplementation(async (invoice: Invoice) => invoice); + }; + // ============ CREATE INVOICE TESTS ============ describe("createInvoice", () => { it("should successfully create an invoice", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - const createdInvoice = { - ...mockInvoice, - netAmount: "950.0000", - }; - mockInvoiceRepository.create.mockReturnValue(createdInvoice); - mockInvoiceRepository.save.mockResolvedValue(createdInvoice); + wireCreatePassthrough(); - const result = await invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - dueDate: new Date("2024-12-31"), - }); + const result = await invoiceService.createInvoice(buildCreateInput()); expect(result.id).toBe("invoice-123"); expect(result.status).toBe(InvoiceStatus.DRAFT); @@ -74,85 +86,46 @@ describe("InvoiceService", () => { }); }); - it("should calculate net amount correctly", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - mockInvoiceRepository.create.mockReturnValue({ - ...mockInvoice, - amount: "1000.00", - discountRate: "10.00", - }); - mockInvoiceRepository.save.mockResolvedValue({ - ...mockInvoice, - amount: "1000.00", - discountRate: "10.00", - netAmount: "900.0000", - }); - - const result = await invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "10.00", - dueDate: new Date("2024-12-31"), - }); - - expect(result.netAmount).toBe("900.0000"); - }); - - it("should calculate net amount precisely for values where floating-point arithmetic rounds wrong", async () => { - mockInvoiceRepository.findOneBy.mockResolvedValue(null); - mockInvoiceRepository.create.mockImplementation((data: Partial) => ({ - ...mockInvoice, - ...data, - })); - mockInvoiceRepository.save.mockImplementation(async (invoice: Invoice) => invoice); - - // 29.99 - 29.99 * 0.5 / 100: naive `parseFloat` arithmetic here used to - // produce "29.8400" instead of the correct "29.8401" because 29.99 and - // 0.5 aren't exactly representable as IEEE-754 doubles. - const result = await invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-002", - customerName: "Test Customer", - amount: "29.99", - discountRate: "0.5", - dueDate: new Date("2024-12-31"), - }); - - expect(mockInvoiceRepository.create).toHaveBeenCalledWith( - expect.objectContaining({ netAmount: "29.8401" }), - ); - expect(result.netAmount).toBe("29.8401"); - }); + // netAmount = amount - amount * discountRate / 100, rounded to 4 dp. + // The 29.99 @ 0.5% row guards a real regression: naive `parseFloat` + // arithmetic produced "29.8400" instead of "29.8401" because 29.99 and + // 0.5 aren't exactly representable as IEEE-754 doubles. + it.each([ + { amount: "1000.00", discountRate: "10.00", expected: "900.0000", note: "round percentages" }, + { amount: "1000.00", discountRate: "5.00", expected: "950.0000", note: "default case" }, + { amount: "29.99", discountRate: "0.5", expected: "29.8401", note: "IEEE-754 rounding trap" }, + { amount: "10000.0000", discountRate: "0.00", expected: "10000.0000", note: "zero discount" }, + ])( + "computes netAmount = $expected for $amount @ $discountRate% ($note)", + async ({ amount, discountRate, expected }) => { + wireCreatePassthrough(); + + const result = await invoiceService.createInvoice( + buildCreateInput({ amount, discountRate }), + ); + + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ netAmount: expected }), + ); + expect(result.netAmount).toBe(expected); + }, + ); it("should reject duplicate invoice number", async () => { mockInvoiceRepository.findOneBy.mockResolvedValue(mockInvoice); await expect( - invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - dueDate: new Date("2024-12-31"), - }) + invoiceService.createInvoice(buildCreateInput()), ).rejects.toThrow(ServiceError); await expect( - invoiceService.createInvoice({ - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - dueDate: new Date("2024-12-31"), - }) + invoiceService.createInvoice(buildCreateInput()), ).rejects.toMatchObject({ code: "invoice_number_exists", statusCode: 409, }); + + expect(mockInvoiceRepository.save).not.toHaveBeenCalled(); }); });