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); + }); +}