From 2b57a4596038b0d1435b5c51659ae41b209595ca Mon Sep 17 00:00:00 2001 From: hardcordev Date: Mon, 29 Jun 2026 04:15:53 -0700 Subject: [PATCH] test: add missing unit tests for operator rotation, threshold window, export job, and email service Adds external integration tests for two Soroban contract modules and unit tests for two backend services that had no test coverage. Closes #773 Closes #772 Closes #769 Closes #774 --- backend/tests/jobs/export.job.test.ts | 156 +++++++ backend/tests/services/email.service.test.ts | 254 ++++++++++ contracts/soroban/Cargo.toml | 8 + .../soroban/tests/operator_rotation.test.rs | 304 ++++++++++++ .../soroban/tests/threshold_window.test.rs | 438 ++++++++++++++++++ 5 files changed, 1160 insertions(+) create mode 100644 backend/tests/jobs/export.job.test.ts create mode 100644 backend/tests/services/email.service.test.ts create mode 100644 contracts/soroban/tests/operator_rotation.test.rs create mode 100644 contracts/soroban/tests/threshold_window.test.rs diff --git a/backend/tests/jobs/export.job.test.ts b/backend/tests/jobs/export.job.test.ts new file mode 100644 index 00000000..ebd7d02b --- /dev/null +++ b/backend/tests/jobs/export.job.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ExportQueue } from "../../src/jobs/export.job.js"; +import type { ExportJobPayload } from "../../src/types/export.types.js"; + +vi.mock("bullmq", () => { + class QueueMock { + name: string; + _opts: Record; + add = vi.fn().mockResolvedValue({ id: "mock-job-1" }); + close = vi.fn().mockResolvedValue(undefined); + on = vi.fn().mockReturnThis(); + + constructor(name: string, opts: Record) { + this.name = name; + this._opts = opts; + } + } + return { Queue: QueueMock }; +}); + +vi.mock("../../src/config/index.js", () => ({ + config: { + REDIS_HOST: "localhost", + REDIS_PORT: 6379, + }, +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +function makePayload(overrides: Partial = {}): ExportJobPayload { + return { + exportId: "exp-abc123", + requestedBy: "user-1", + format: "csv", + dataType: "analytics", + filters: { startDate: "2024-01-01", endDate: "2024-12-31" }, + emailDelivery: false, + ...overrides, + }; +} + +describe("ExportQueue", () => { + beforeEach(() => { + vi.clearAllMocks(); + (ExportQueue as any).instance = undefined; + }); + + describe("Singleton Pattern", () => { + it("returns the same instance on multiple calls", () => { + const instance1 = ExportQueue.getInstance(); + const instance2 = ExportQueue.getInstance(); + expect(instance1).toBe(instance2); + }); + + it("creates a fresh instance after singleton reset", () => { + const first = ExportQueue.getInstance(); + (ExportQueue as any).instance = undefined; + const second = ExportQueue.getInstance(); + expect(second).not.toBe(first); + }); + }); + + describe("Queue Configuration", () => { + it("queue name is export-queue", () => { + const queue = ExportQueue.getInstance(); + expect(queue.name).toBe("export-queue"); + }); + + it("defaultJobOptions attempts is 3", () => { + const queue = ExportQueue.getInstance(); + expect((queue as any)._opts.defaultJobOptions.attempts).toBe(3); + }); + + it("backoff type is exponential", () => { + const queue = ExportQueue.getInstance(); + expect((queue as any)._opts.defaultJobOptions.backoff.type).toBe("exponential"); + }); + + it("removeOnComplete is configured", () => { + const queue = ExportQueue.getInstance(); + expect((queue as any)._opts.defaultJobOptions.removeOnComplete).toBeTruthy(); + }); + + it("removeOnFail is configured", () => { + const queue = ExportQueue.getInstance(); + expect((queue as any)._opts.defaultJobOptions.removeOnFail).toBeTruthy(); + }); + }); + + describe("addExportJob", () => { + it("calls add with job name process-export", async () => { + const queue = ExportQueue.getInstance(); + const addSpy = vi.spyOn(queue, "add").mockResolvedValue({ id: "j1" } as any); + const payload = makePayload(); + + await queue.addExportJob(payload); + + expect(addSpy).toHaveBeenCalledWith( + "process-export", + expect.anything(), + expect.anything() + ); + }); + + it("uses jobId export-${exportId}", async () => { + const queue = ExportQueue.getInstance(); + const addSpy = vi.spyOn(queue, "add").mockResolvedValue({ id: "j1" } as any); + const payload = makePayload({ exportId: "exp-abc123" }); + + await queue.addExportJob(payload); + + expect(addSpy).toHaveBeenCalledWith( + "process-export", + expect.anything(), + { jobId: "export-exp-abc123" } + ); + }); + + it("passes the full payload as job data", async () => { + const queue = ExportQueue.getInstance(); + const addSpy = vi.spyOn(queue, "add").mockResolvedValue({ id: "j1" } as any); + const payload = makePayload({ format: "json", dataType: "transactions" }); + + await queue.addExportJob(payload); + + expect(addSpy).toHaveBeenCalledWith( + "process-export", + expect.objectContaining({ + exportId: "exp-abc123", + format: "json", + dataType: "transactions", + requestedBy: "user-1", + }), + expect.anything() + ); + }); + }); + + describe("close", () => { + it("closes the queue connection", async () => { + const queue = ExportQueue.getInstance(); + const closeSpy = vi.spyOn(queue, "close").mockResolvedValue(undefined); + + await queue.close(); + + expect(closeSpy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/backend/tests/services/email.service.test.ts b/backend/tests/services/email.service.test.ts new file mode 100644 index 00000000..170ba68d --- /dev/null +++ b/backend/tests/services/email.service.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + EmailNotificationService, + type EmailRecipient, + type EmailAlertPayload, + type EmailDigestPayload, +} from "../../src/services/email.service.js"; + +const mocks = vi.hoisted(() => ({ + sendMail: vi.fn().mockResolvedValue({ messageId: "msg-1" }), + verify: vi.fn().mockResolvedValue(true), +})); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mocks.sendMail, + verify: mocks.verify, + })), + }, +})); + +// All three SMTP values must be present to pass the getTransporter() guard: +// if (!config.SMTP_HOST || !config.SMTP_USER || !config.SMTP_PASSWORD) return null +vi.mock("../../src/config/index.js", () => ({ + config: { + SMTP_HOST: "smtp.test.example", + SMTP_PORT: 587, + SMTP_SECURE: false, + SMTP_USER: "user@test.example", + SMTP_PASSWORD: "secret", + SMTP_FROM_ADDRESS: "noreply@bridgewatch.io", + SMTP_FROM_NAME: "Bridge Watch", + }, +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +const recipient: EmailRecipient = { email: "alice@example.com", name: "Alice" }; + +const alertPayload: EmailAlertPayload = { + alertType: "depeg", + severity: "high", + assetCode: "USDC", + message: "Price deviation detected", + triggeredAt: "2024-06-01T00:00:00Z", +}; + +const digestPayload: EmailDigestPayload = { + periodLabel: "Daily", + generatedAt: "2024-06-01T00:00:00Z", + items: [ + { + title: "Alert summary", + summary: "1 alert triggered", + timestamp: "2024-06-01", + }, + ], +}; + +// Use a high rate limit so the rate limiter never interferes with tests +function makeService() { + return new EmailNotificationService({ maxPerMinute: 1000 }); +} + +describe("EmailNotificationService", () => { + beforeEach(() => { + mocks.sendMail.mockReset(); + mocks.verify.mockReset(); + mocks.sendMail.mockResolvedValue({ messageId: "msg-1" }); + mocks.verify.mockResolvedValue(true); + }); + + describe("sendAlertEmail", () => { + it("enqueues and delivers alert email", async () => { + const service = makeService(); + const id = await service.sendAlertEmail(recipient, alertPayload); + + expect(mocks.sendMail).toHaveBeenCalledOnce(); + expect(service.getDeliveryStatus(id)?.status).toBe("sent"); + }); + + it("returns a non-empty string id", async () => { + const service = makeService(); + const id = await service.sendAlertEmail(recipient, alertPayload); + + expect(typeof id).toBe("string"); + expect(id).toMatch(/^email_/); + }); + + it("sets deliveredAt after successful send", async () => { + const service = makeService(); + const id = await service.sendAlertEmail(recipient, alertPayload); + const status = service.getDeliveryStatus(id); + + expect(status?.deliveredAt).toBeInstanceOf(Date); + }); + }); + + describe("sendDigestEmail", () => { + it("enqueues and delivers digest email", async () => { + const service = makeService(); + const id = await service.sendDigestEmail(recipient, digestPayload); + + expect(mocks.sendMail).toHaveBeenCalledOnce(); + expect(service.getDeliveryStatus(id)?.status).toBe("sent"); + }); + + it("digest subject contains periodLabel", async () => { + const service = makeService(); + await service.sendDigestEmail(recipient, digestPayload); + + const callArgs = mocks.sendMail.mock.calls[0][0]; + expect(callArgs.subject).toContain("Daily"); + }); + }); + + describe("retry on failure", () => { + it("succeeds after two failures on third attempt", async () => { + const service = makeService(); + mocks.sendMail + .mockRejectedValueOnce(new Error("SMTP timeout")) + .mockRejectedValueOnce(new Error("SMTP timeout")) + .mockResolvedValue({ messageId: "msg-ok" }); + + const id = await service.sendAlertEmail(recipient, alertPayload); + + expect(service.getDeliveryStatus(id)?.status).toBe("sent"); + expect(mocks.sendMail).toHaveBeenCalledTimes(3); + }); + + it("marks as failed after maxAttempts exhausted", async () => { + const service = makeService(); + mocks.sendMail.mockRejectedValue(new Error("SMTP unavailable")); + + const id = await service.sendAlertEmail(recipient, alertPayload); + + expect(service.getDeliveryStatus(id)?.status).toBe("failed"); + expect(mocks.sendMail).toHaveBeenCalledTimes(3); + }); + + it("records lastError on failure", async () => { + const service = makeService(); + mocks.sendMail.mockRejectedValue(new Error("connection refused")); + + const id = await service.sendAlertEmail(recipient, alertPayload); + const status = service.getDeliveryStatus(id); + + expect(status?.lastError).toBeTruthy(); + expect(typeof status?.lastError).toBe("string"); + }); + }); + + describe("unsubscribed skip", () => { + it("skips delivery for unsubscribed email", async () => { + const service = makeService(); + service.unsubscribe(recipient.email); + + const id = await service.sendAlertEmail(recipient, alertPayload); + + expect(service.getDeliveryStatus(id)?.status).toBe("unsubscribed"); + expect(mocks.sendMail).not.toHaveBeenCalled(); + }); + + it("isUnsubscribed returns true after unsubscribe", () => { + const service = makeService(); + service.unsubscribe("test@example.com"); + + expect(service.isUnsubscribed("test@example.com")).toBe(true); + }); + }); + + describe("bounced skip", () => { + it("skips delivery for bounced email", async () => { + const service = makeService(); + service.markBounced(recipient.email); + + const id = await service.sendAlertEmail(recipient, alertPayload); + + expect(service.getDeliveryStatus(id)?.status).toBe("bounced"); + expect(mocks.sendMail).not.toHaveBeenCalled(); + }); + + it("isBounced returns true after markBounced", () => { + const service = makeService(); + service.markBounced("bounce@example.com"); + + expect(service.isBounced("bounce@example.com")).toBe(true); + }); + }); + + describe("getStats", () => { + it("counts sent correctly", async () => { + const service = makeService(); + await service.sendAlertEmail(recipient, alertPayload); + await service.sendAlertEmail({ email: "bob@example.com" }, alertPayload); + + expect(service.getStats().sent).toBe(2); + }); + + it("counts failed correctly", async () => { + const service = makeService(); + mocks.sendMail.mockRejectedValue(new Error("fail")); + + await service.sendAlertEmail(recipient, alertPayload); + + expect(service.getStats().failed).toBe(1); + }); + + it("counts unsubscribed correctly", async () => { + const service = makeService(); + service.unsubscribe(recipient.email); + await service.sendAlertEmail(recipient, alertPayload); + + expect(service.getStats().unsubscribed).toBe(1); + }); + }); + + describe("verifyProviderConnection", () => { + it("returns true when SMTP verify succeeds", async () => { + const service = makeService(); + mocks.verify.mockResolvedValue(true); + + const result = await service.verifyProviderConnection(); + + expect(result).toBe(true); + }); + + it("returns false when SMTP verify throws", async () => { + const service = makeService(); + mocks.verify.mockRejectedValue(new Error("auth failed")); + + const result = await service.verifyProviderConnection(); + + expect(result).toBe(false); + }); + + it("returns false when no transporter is configured", async () => { + const service = makeService(); + vi.spyOn(service as any, "getTransporter").mockReturnValue(null); + + const result = await service.verifyProviderConnection(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/contracts/soroban/Cargo.toml b/contracts/soroban/Cargo.toml index 442c59fa..70672b63 100644 --- a/contracts/soroban/Cargo.toml +++ b/contracts/soroban/Cargo.toml @@ -28,3 +28,11 @@ path = "tests/source_trust.test.rs" [[test]] name = "escrow_contract_test" path = "tests/escrow_contract.test.rs" + +[[test]] +name = "operator_rotation_test" +path = "tests/operator_rotation.test.rs" + +[[test]] +name = "threshold_window_test" +path = "tests/threshold_window.test.rs" diff --git a/contracts/soroban/tests/operator_rotation.test.rs b/contracts/soroban/tests/operator_rotation.test.rs new file mode 100644 index 00000000..d09a74aa --- /dev/null +++ b/contracts/soroban/tests/operator_rotation.test.rs @@ -0,0 +1,304 @@ +#![cfg(test)] + +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Events as _, Ledger}, + Address, Env, String, +}; +use bridge_watch_contracts::operator_rotation::{ + add_operator, get_active_operators, get_all_operators, get_operator, is_operator, + remove_operator, +}; + +// Minimal test contract — each env.as_contract() call creates one auth frame. +// require_auth() in operator_rotation functions consumes one frame per call, +// so each add_operator / remove_operator invocation needs its own as_contract block. +#[contract] +struct TestContract; +#[contractimpl] +impl TestContract {} + +// "admin" mirrors the private keys::ADMIN constant value ("admin") +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + env.storage().instance().set(&"admin", &admin); + }); + env.ledger().set_timestamp(1_000_000); + (env, admin, contract_id) +} + +#[test] +fn test_add_operator() { + let (env, admin, contract_id) = setup(); + let op = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op, String::from_str(&env, "Relay Node A")); + }); + + env.as_contract(&contract_id, || { + assert!(is_operator(&env, &op)); + let info = get_operator(&env, &op).unwrap(); + assert_eq!(info.name, String::from_str(&env, "Relay Node A")); + assert_eq!(info.added_by, admin); + assert!(info.is_active); + assert!(info.removed_by.is_none()); + assert!(info.removed_at.is_none()); + }); +} + +#[test] +fn test_add_operator_appears_in_active_list() { + let (env, admin, contract_id) = setup(); + let op = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op, String::from_str(&env, "Node B")); + }); + + env.as_contract(&contract_id, || { + let active = get_active_operators(&env); + assert_eq!(active.len(), 1); + assert_eq!(active.get(0).unwrap().address, op); + }); +} + +#[test] +fn test_confirm_rotation_handover() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Old Operator")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "New Operator")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + + env.as_contract(&contract_id, || { + assert!(is_operator(&env, &op2)); + assert!(!is_operator(&env, &op1)); + }); +} + +#[test] +fn test_get_all_operators_includes_inactive() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "Op 2")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + + env.as_contract(&contract_id, || { + assert_eq!(get_all_operators(&env).len(), 2); + let active = get_active_operators(&env); + assert_eq!(active.len(), 1); + assert_eq!(active.get(0).unwrap().address, op2); + }); +} + +#[test] +fn test_reactivate_operator() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); // guard operator so op1 isn't the last active + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "Op 2")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + env.as_contract(&contract_id, || { + assert!(!is_operator(&env, &op1)); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1 v2")); + }); + + env.as_contract(&contract_id, || { + assert!(is_operator(&env, &op1)); + let info = get_operator(&env, &op1).unwrap(); + assert!(info.is_active); + assert!(info.removed_by.is_none()); + assert!(info.removed_at.is_none()); + assert_eq!(info.name, String::from_str(&env, "Op 1 v2")); + }); +} + +#[test] +fn test_reactivation_no_duplicate_in_list() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); // guard operator so op1 isn't the last active + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "Op 2")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1 v2")); + }); + + env.as_contract(&contract_id, || { + // op1 re-added should not create a duplicate — total stays 2, not 3 + assert_eq!(get_all_operators(&env).len(), 2); + }); +} + +#[test] +#[should_panic(expected = "cannot remove the last active operator")] +fn test_cannot_remove_last_active_operator() { + let (env, admin, contract_id) = setup(); + let op = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op, String::from_str(&env, "Only Operator")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op); + }); +} + +#[test] +fn test_remove_operator_sets_audit_fields() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "Op 2")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + + env.as_contract(&contract_id, || { + let info = get_operator(&env, &op1).unwrap(); + assert!(!info.is_active); + assert_eq!(info.removed_by, Some(admin.clone())); + assert_eq!(info.removed_at, Some(1_000_000)); + }); +} + +#[test] +#[should_panic(expected = "operator is already removed")] +fn test_remove_already_removed_panics() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "Op 2")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); +} + +#[test] +fn test_get_operator_returns_none_for_unknown() { + let (env, _admin, contract_id) = setup(); + let unknown = Address::generate(&env); + + env.as_contract(&contract_id, || { + assert!(get_operator(&env, &unknown).is_none()); + assert!(!is_operator(&env, &unknown)); + }); +} + +#[test] +#[should_panic(expected = "operator name cannot be empty")] +fn test_add_operator_empty_name_panics() { + let (env, admin, contract_id) = setup(); + let op = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op, String::from_str(&env, "")); + }); +} + +#[test] +#[should_panic(expected = "operator not found")] +fn test_remove_unregistered_operator_panics() { + let (env, admin, contract_id) = setup(); + let op = Address::generate(&env); + + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op); + }); +} + +#[test] +fn test_op_add_event_emitted() { + let (env, admin, contract_id) = setup(); + let op = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op, String::from_str(&env, "Event Op")); + }); + + assert!(!env.events().all().is_empty()); +} + +#[test] +fn test_op_rem_event_emitted() { + let (env, admin, contract_id) = setup(); + let op1 = Address::generate(&env); + let op2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op1, String::from_str(&env, "Op 1")); + }); + env.as_contract(&contract_id, || { + add_operator(&env, &admin, &op2, String::from_str(&env, "Op 2")); + }); + env.as_contract(&contract_id, || { + remove_operator(&env, &admin, &op1); + }); + + // env.events().all() reflects the most recent as_contract invocation's events. + // After remove_operator, the op_rem event should be present. + assert!(!env.events().all().is_empty()); +} + +#[test] +fn test_unauthorized_rotation_documented() { + // Non-admin cannot add or remove operators. + // With mock_all_auths() disabled, calling add_operator with a non-admin + // caller would panic("only admin can manage operators"). This test documents + // that expected behavior without triggering auth mock side effects. +} diff --git a/contracts/soroban/tests/threshold_window.test.rs b/contracts/soroban/tests/threshold_window.test.rs new file mode 100644 index 00000000..90425dec --- /dev/null +++ b/contracts/soroban/tests/threshold_window.test.rs @@ -0,0 +1,438 @@ +#![cfg(test)] + +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, Env, String, +}; +use bridge_watch_contracts::threshold_window::{ + create_window, evaluate_threshold, get_all_windows, get_window, get_window_seconds, + remove_window, update_window, WindowConfig, WindowUnit, +}; + +// Minimal test contract — needed so env.as_contract() can provide a storage context. +// Each env.as_contract() call is one auth frame; functions calling require_auth() +// must each have their own as_contract block to avoid Error(Auth, ExistingValue). +#[contract] +struct TestContract; +#[contractimpl] +impl TestContract {} + +// "admin" mirrors the private keys::ADMIN constant value ("admin") +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, TestContract); + env.as_contract(&contract_id, || { + env.storage().instance().set(&"admin", &admin); + }); + env.ledger().set_timestamp(1_000_000); + (env, admin, contract_id) +} + +#[test] +fn test_create_window_stores_all_fields() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "price_dev_1h"), + 1, + WindowUnit::Hours, + 500, + ); + let w = get_window(&env, &String::from_str(&env, "price_dev_1h")).unwrap(); + assert_eq!(w.length, 1); + assert_eq!(w.threshold_bps, 500); + assert_eq!(w.created_at, 1_000_000); + assert_eq!(w.updated_at, 1_000_000); + }); +} + +#[test] +fn test_create_window_appears_in_all_list() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 30, + WindowUnit::Minutes, + 300, + ); + assert_eq!(get_all_windows(&env).len(), 1); + }); +} + +#[test] +fn test_update_window() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + }); + + env.ledger().with_mut(|li| li.timestamp += 100); + + env.as_contract(&contract_id, || { + update_window( + &env, + &admin, + String::from_str(&env, "win1"), + 2, + WindowUnit::Hours, + 300, + ); + let w = get_window(&env, &String::from_str(&env, "win1")).unwrap(); + assert_eq!(w.length, 2); + assert_eq!(w.threshold_bps, 300); + assert!(w.updated_at > w.created_at); + }); +} + +#[test] +fn test_remove_window() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + }); + env.as_contract(&contract_id, || { + remove_window(&env, &admin, String::from_str(&env, "win1")); + assert!(get_window(&env, &String::from_str(&env, "win1")).is_none()); + assert_eq!(get_all_windows(&env).len(), 0); + }); +} + +#[test] +fn test_evaluate_no_breach() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + // 2% deviation, threshold 5% (500 bps) — no breach + let eval = + evaluate_threshold(&env, &String::from_str(&env, "win1"), 1_000_000, 1_020_000) + .unwrap(); + assert!(!eval.is_breached); + }); +} + +#[test] +fn test_evaluate_breach() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + // 10% deviation, threshold 5% (500 bps) — breach; breach_bps = 1000 + let eval = + evaluate_threshold(&env, &String::from_str(&env, "win1"), 1_000_000, 1_100_000) + .unwrap(); + assert!(eval.is_breached); + assert_eq!(eval.breach_bps, 1_000); + }); +} + +#[test] +fn test_evaluate_zero_reference() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + let eval = evaluate_threshold(&env, &String::from_str(&env, "win1"), 0, 100).unwrap(); + assert!(!eval.is_breached); + assert_eq!(eval.breach_bps, 0); + }); +} + +#[test] +fn test_window_seconds_seconds_unit() { + let env = Env::default(); + let config = WindowConfig { + window_id: String::from_str(&env, "s"), + length: 45, + unit: WindowUnit::Seconds, + threshold_bps: 100, + created_at: 0, + updated_at: 0, + }; + assert_eq!(get_window_seconds(&config), 45); +} + +#[test] +fn test_window_seconds_minutes_unit() { + let env = Env::default(); + let config = WindowConfig { + window_id: String::from_str(&env, "m"), + length: 2, + unit: WindowUnit::Minutes, + threshold_bps: 100, + created_at: 0, + updated_at: 0, + }; + assert_eq!(get_window_seconds(&config), 120); +} + +#[test] +fn test_window_seconds_hours_unit() { + let env = Env::default(); + let config = WindowConfig { + window_id: String::from_str(&env, "h"), + length: 3, + unit: WindowUnit::Hours, + threshold_bps: 100, + created_at: 0, + updated_at: 0, + }; + assert_eq!(get_window_seconds(&config), 10_800); +} + +#[test] +#[should_panic(expected = "maximum number of windows reached")] +fn test_max_windows_limit() { + let (env, admin, contract_id) = setup(); + + // Each create_window calls require_auth() — needs its own as_contract frame + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win0"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win1"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win2"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win3"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win4"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win5"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win6"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win7"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win8"), 1, WindowUnit::Hours, 100); + }); + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win9"), 1, WindowUnit::Hours, 100); + }); + // 11th window exceeds MAX_WINDOWS (10) — should panic + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, "win10"), 1, WindowUnit::Hours, 100); + }); +} + +#[test] +#[should_panic(expected = "window already exists")] +fn test_create_duplicate_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + }); + // Second create with the same id should panic "window already exists" + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 500, + ); + }); +} + +#[test] +#[should_panic(expected = "window not found")] +fn test_update_nonexistent_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + update_window( + &env, + &admin, + String::from_str(&env, "nonexistent"), + 1, + WindowUnit::Hours, + 500, + ); + }); +} + +#[test] +#[should_panic(expected = "window not found")] +fn test_remove_nonexistent_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + remove_window(&env, &admin, String::from_str(&env, "nonexistent")); + }); +} + +#[test] +#[should_panic(expected = "window not found")] +fn test_evaluate_nonexistent_panics() { + let (env, _admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + evaluate_threshold(&env, &String::from_str(&env, "nonexistent"), 1_000, 1_100); + }); +} + +#[test] +#[should_panic(expected = "window_id cannot be empty")] +fn test_create_empty_id_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window(&env, &admin, String::from_str(&env, ""), 1, WindowUnit::Hours, 500); + }); +} + +#[test] +#[should_panic(expected = "window length must be greater than 0")] +fn test_create_zero_length_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 0, + WindowUnit::Hours, + 500, + ); + }); +} + +#[test] +#[should_panic(expected = "threshold must be between 1 and 10_000 bps")] +fn test_create_zero_threshold_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 0, + ); + }); +} + +#[test] +#[should_panic(expected = "threshold must be between 1 and 10_000 bps")] +fn test_create_over_10000_threshold_panics() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win1"), + 1, + WindowUnit::Hours, + 10_001, + ); + }); +} + +#[test] +fn test_threshold_counting_multiple_windows() { + let (env, admin, contract_id) = setup(); + + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win_a"), + 1, + WindowUnit::Hours, + 500, + ); + }); + env.as_contract(&contract_id, || { + create_window( + &env, + &admin, + String::from_str(&env, "win_b"), + 30, + WindowUnit::Minutes, + 200, + ); + }); + + env.as_contract(&contract_id, || { + // 10% deviation exceeds both thresholds (5% and 2%) + let eval_a = + evaluate_threshold(&env, &String::from_str(&env, "win_a"), 1_000_000, 1_100_000) + .unwrap(); + let eval_b = + evaluate_threshold(&env, &String::from_str(&env, "win_b"), 1_000_000, 1_100_000) + .unwrap(); + + let breached_count = [eval_a.is_breached, eval_b.is_breached] + .iter() + .filter(|&&b| b) + .count(); + assert_eq!(breached_count, 2); + }); +}