From b4e10c5a48da9d45e7d51cf5aee67581d99f84bb Mon Sep 17 00:00:00 2001 From: brite-side0 Date: Mon, 28 Sep 2026 10:56:32 +0000 Subject: [PATCH 1/3] fix: #1504 Add unit tests for OpenAPI spec generation module Closes #1504 --- .github/workflows/openapi-validation.yml | 35 +++++++- services/api/src/openapi_spec.rs | 100 +++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/.github/workflows/openapi-validation.yml b/.github/workflows/openapi-validation.yml index b5bf259f..927bcb57 100644 --- a/.github/workflows/openapi-validation.yml +++ b/.github/workflows/openapi-validation.yml @@ -75,9 +75,42 @@ jobs: echo " 2. If you updated openapi.yaml: update SPEC_ROUTES in the contract test." exit 1 + spec-drift: + name: OpenAPI Spec Drift Check + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + + - name: Cache cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-api-openapi-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Regenerate openapi.yaml + run: cargo run --bin generate_openapi + working-directory: services/api + + - name: Diff regenerated spec against committed file + run: | + if ! git diff --exit-code -- services/api/openapi.yaml; then + echo "openapi.yaml is out of date with the spec-building code." + echo "" + echo "To fix, regenerate the spec locally and commit the result:" + echo " cd services/api && cargo run --bin generate_openapi" + exit 1 + fi + all-openapi-checks-passed: name: All OpenAPI Checks Passed - needs: [spectral-lint, contract-tests] + needs: [spectral-lint, contract-tests, spec-drift] runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/services/api/src/openapi_spec.rs b/services/api/src/openapi_spec.rs index 1dc3f8cc..25bbeaa0 100644 --- a/services/api/src/openapi_spec.rs +++ b/services/api/src/openapi_spec.rs @@ -81,3 +81,103 @@ use crate::pagination::PaginationQuery; ) )] pub struct ApiDoc; + +#[cfg(test)] +mod tests { + use super::*; + use utoipa::openapi::OpenApi as OpenApiDoc; + + fn build_spec() -> OpenApiDoc { + ApiDoc::openapi() + } + + #[test] + fn spec_includes_expected_paths() { + let spec = build_spec(); + let paths = &spec.paths.paths; + + let expected = [ + "/health", + "/api/v1/newsletter/subscribe", + "/api/v1/newsletter/confirm", + "/api/v1/newsletter/unsubscribe", + "/api/v1/statistics", + "/api/v1/markets/featured", + "/api/v1/markets/resolve", + "/api/v1/blockchain/health", + "/api/v1/blockchain/replay", + "/api/v1/email/preview", + "/api/v1/email/send-test", + "/api/v1/email/analytics", + "/api/v1/webhooks/sendgrid", + "/api/v1/audit/logs", + "/api/v1/audit/statistics", + ]; + + for path in expected { + assert!( + paths.contains_key(path), + "expected OpenAPI spec to contain path `{path}`, found: {:?}", + paths.keys().collect::>() + ); + } + } + + #[test] + fn spec_includes_expected_security_schemes() { + let spec = build_spec(); + let components = spec + .components + .as_ref() + .expect("generated spec should define components"); + + assert!( + components.security_schemes.contains_key("api_key"), + "expected `api_key` security scheme, found: {:?}", + components.security_schemes.keys().collect::>() + ); + + let security = spec + .security + .as_ref() + .expect("generated spec should declare a global security requirement"); + assert!( + security + .iter() + .any(|req| req.contains_key("api_key")), + "expected global security requirement to reference `api_key`" + ); + } + + #[test] + fn spec_includes_expected_server_url() { + let spec = build_spec(); + let servers = spec + .servers + .as_ref() + .expect("generated spec should declare at least one server"); + + assert!( + servers.iter().any(|s| s.url == "/api/v1"), + "expected server URL `/api/v1`, found: {:?}", + servers.iter().map(|s| s.url.clone()).collect::>() + ); + } + + #[test] + fn spec_includes_expected_schemas() { + let spec = build_spec(); + let components = spec + .components + .as_ref() + .expect("generated spec should define components"); + + for schema in ["ApiError", "FeaturedMarketView", "NewsletterResponse"] { + assert!( + components.schemas.contains_key(schema), + "expected schema `{schema}`, found: {:?}", + components.schemas.keys().collect::>() + ); + } + } +} From 02bd59872514483bf10444fdfb7b7c173bf2c0f5 Mon Sep 17 00:00:00 2001 From: brite-side0 Date: Mon, 28 Sep 2026 10:56:45 +0000 Subject: [PATCH 2/3] fix: #1505 Add unit tests for email/types.rs serialization contracts Closes #1505 --- services/api/src/email/types.rs | 323 ++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/services/api/src/email/types.rs b/services/api/src/email/types.rs index 1df8ecbf..f121ab33 100644 --- a/services/api/src/email/types.rs +++ b/services/api/src/email/types.rs @@ -144,3 +144,326 @@ pub struct EmailAnalytics { pub complained_count: i32, pub unsubscribed_count: i32, } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use serde_json::json; + + fn sample_job() -> EmailJob { + let ts = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); + EmailJob { + id: Uuid::nil(), + job_type: EmailJobType::WelcomeEmail.as_str().to_string(), + recipient_email: "user@example.com".to_string(), + template_name: "welcome".to_string(), + template_data: json!({ "name": "Ada" }), + status: EmailJobStatus::Pending.as_str().to_string(), + priority: 5, + attempts: 0, + max_attempts: 3, + scheduled_at: ts, + started_at: None, + completed_at: None, + failed_at: None, + error_message: None, + created_at: ts, + updated_at: ts, + } + } + + fn sample_event() -> EmailEvent { + let ts = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); + EmailEvent { + id: Uuid::nil(), + email_job_id: Some(Uuid::nil()), + message_id: Some("msg-1".to_string()), + event_type: EmailEventType::Delivered.as_str().to_string(), + recipient_email: "user@example.com".to_string(), + timestamp: ts, + metadata: json!({ "sg_event_id": "abc" }), + created_at: ts, + } + } + + fn sample_suppression() -> EmailSuppression { + let ts = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(); + EmailSuppression { + id: Uuid::nil(), + email: "user@example.com".to_string(), + suppression_type: SuppressionType::Bounce.as_str().to_string(), + reason: Some("hard bounce".to_string()), + bounce_type: Some("hard".to_string()), + created_at: ts, + updated_at: ts, + } + } + + fn sample_analytics() -> EmailAnalytics { + EmailAnalytics { + template_name: "welcome".to_string(), + variant_name: Some("a".to_string()), + date: chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(), + sent_count: 10, + delivered_count: 9, + opened_count: 4, + clicked_count: 2, + bounced_count: 1, + complained_count: 0, + unsubscribed_count: 1, + } + } + + #[test] + fn email_job_type_round_trips_and_matches_as_str() { + let variants = [ + EmailJobType::NewsletterConfirmation, + EmailJobType::WaitlistConfirmation, + EmailJobType::ContactFormAutoResponse, + EmailJobType::WelcomeEmail, + EmailJobType::Custom("custom_job".to_string()), + ]; + for variant in variants { + let encoded = serde_json::to_string(&variant).unwrap(); + let decoded: EmailJobType = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.as_str(), variant.as_str()); + } + } + + #[test] + fn email_job_status_round_trips_and_matches_as_str() { + let variants = [ + EmailJobStatus::Pending, + EmailJobStatus::Processing, + EmailJobStatus::Completed, + EmailJobStatus::Failed, + EmailJobStatus::Cancelled, + ]; + for variant in variants { + let encoded = serde_json::to_string(&variant).unwrap(); + let decoded: EmailJobStatus = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.as_str(), variant.as_str()); + } + } + + #[test] + fn email_event_type_round_trips_and_matches_as_str() { + let variants = [ + EmailEventType::Sent, + EmailEventType::Delivered, + EmailEventType::Opened, + EmailEventType::Clicked, + EmailEventType::Bounced, + EmailEventType::Complained, + EmailEventType::Unsubscribed, + ]; + for variant in variants { + let encoded = serde_json::to_string(&variant).unwrap(); + let decoded: EmailEventType = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.as_str(), variant.as_str()); + } + } + + #[test] + fn suppression_type_round_trips_and_matches_as_str() { + let variants = [ + SuppressionType::Bounce, + SuppressionType::Complaint, + SuppressionType::Unsubscribe, + SuppressionType::Manual, + ]; + for variant in variants { + let encoded = serde_json::to_string(&variant).unwrap(); + let decoded: SuppressionType = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.as_str(), variant.as_str()); + } + } + + #[test] + fn email_job_round_trips() { + let job = sample_job(); + let encoded = serde_json::to_string(&job).unwrap(); + let decoded: EmailJob = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.id, job.id); + assert_eq!(decoded.job_type, job.job_type); + assert_eq!(decoded.recipient_email, job.recipient_email); + assert_eq!(decoded.template_name, job.template_name); + assert_eq!(decoded.template_data, job.template_data); + assert_eq!(decoded.status, job.status); + assert_eq!(decoded.priority, job.priority); + assert_eq!(decoded.attempts, job.attempts); + assert_eq!(decoded.max_attempts, job.max_attempts); + assert_eq!(decoded.scheduled_at, job.scheduled_at); + assert_eq!(decoded.started_at, job.started_at); + assert_eq!(decoded.completed_at, job.completed_at); + assert_eq!(decoded.failed_at, job.failed_at); + assert_eq!(decoded.error_message, job.error_message); + assert_eq!(decoded.created_at, job.created_at); + assert_eq!(decoded.updated_at, job.updated_at); + } + + #[test] + fn email_job_serializes_expected_field_names() { + let value = serde_json::to_value(sample_job()).unwrap(); + let obj = value.as_object().unwrap(); + for field in [ + "id", + "job_type", + "recipient_email", + "template_name", + "template_data", + "status", + "priority", + "attempts", + "max_attempts", + "scheduled_at", + "started_at", + "completed_at", + "failed_at", + "error_message", + "created_at", + "updated_at", + ] { + assert!(obj.contains_key(field), "missing field: {field}"); + } + assert_eq!(obj.len(), 16); + } + + #[test] + fn email_event_round_trips() { + let event = sample_event(); + let encoded = serde_json::to_string(&event).unwrap(); + let decoded: EmailEvent = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.id, event.id); + assert_eq!(decoded.email_job_id, event.email_job_id); + assert_eq!(decoded.message_id, event.message_id); + assert_eq!(decoded.event_type, event.event_type); + assert_eq!(decoded.recipient_email, event.recipient_email); + assert_eq!(decoded.timestamp, event.timestamp); + assert_eq!(decoded.metadata, event.metadata); + assert_eq!(decoded.created_at, event.created_at); + } + + #[test] + fn email_event_serializes_expected_field_names() { + let value = serde_json::to_value(sample_event()).unwrap(); + let obj = value.as_object().unwrap(); + for field in [ + "id", + "email_job_id", + "message_id", + "event_type", + "recipient_email", + "timestamp", + "metadata", + "created_at", + ] { + assert!(obj.contains_key(field), "missing field: {field}"); + } + assert_eq!(obj.len(), 8); + } + + #[test] + fn email_suppression_round_trips() { + let suppression = sample_suppression(); + let encoded = serde_json::to_string(&suppression).unwrap(); + let decoded: EmailSuppression = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.id, suppression.id); + assert_eq!(decoded.email, suppression.email); + assert_eq!(decoded.suppression_type, suppression.suppression_type); + assert_eq!(decoded.reason, suppression.reason); + assert_eq!(decoded.bounce_type, suppression.bounce_type); + assert_eq!(decoded.created_at, suppression.created_at); + assert_eq!(decoded.updated_at, suppression.updated_at); + } + + #[test] + fn email_suppression_serializes_expected_field_names() { + let value = serde_json::to_value(sample_suppression()).unwrap(); + let obj = value.as_object().unwrap(); + for field in [ + "id", + "email", + "suppression_type", + "reason", + "bounce_type", + "created_at", + "updated_at", + ] { + assert!(obj.contains_key(field), "missing field: {field}"); + } + assert_eq!(obj.len(), 7); + } + + #[test] + fn email_analytics_round_trips() { + let analytics = sample_analytics(); + let encoded = serde_json::to_string(&analytics).unwrap(); + let decoded: EmailAnalytics = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.template_name, analytics.template_name); + assert_eq!(decoded.variant_name, analytics.variant_name); + assert_eq!(decoded.date, analytics.date); + assert_eq!(decoded.sent_count, analytics.sent_count); + assert_eq!(decoded.delivered_count, analytics.delivered_count); + assert_eq!(decoded.opened_count, analytics.opened_count); + assert_eq!(decoded.clicked_count, analytics.clicked_count); + assert_eq!(decoded.bounced_count, analytics.bounced_count); + assert_eq!(decoded.complained_count, analytics.complained_count); + assert_eq!(decoded.unsubscribed_count, analytics.unsubscribed_count); + } + + #[test] + fn email_analytics_serializes_expected_field_names() { + let value = serde_json::to_value(sample_analytics()).unwrap(); + let obj = value.as_object().unwrap(); + for field in [ + "template_name", + "variant_name", + "date", + "sent_count", + "delivered_count", + "opened_count", + "clicked_count", + "bounced_count", + "complained_count", + "unsubscribed_count", + ] { + assert!(obj.contains_key(field), "missing field: {field}"); + } + assert_eq!(obj.len(), 10); + } + + #[test] + fn optional_fields_accept_null() { + let mut job = serde_json::to_value(sample_job()).unwrap(); + job["started_at"] = json!(null); + job["completed_at"] = json!(null); + job["failed_at"] = json!(null); + job["error_message"] = json!(null); + let decoded: EmailJob = serde_json::from_value(job).unwrap(); + assert!(decoded.started_at.is_none()); + assert!(decoded.completed_at.is_none()); + assert!(decoded.failed_at.is_none()); + assert!(decoded.error_message.is_none()); + + let mut event = serde_json::to_value(sample_event()).unwrap(); + event["email_job_id"] = json!(null); + event["message_id"] = json!(null); + let decoded: EmailEvent = serde_json::from_value(event).unwrap(); + assert!(decoded.email_job_id.is_none()); + assert!(decoded.message_id.is_none()); + + let mut suppression = serde_json::to_value(sample_suppression()).unwrap(); + suppression["reason"] = json!(null); + suppression["bounce_type"] = json!(null); + let decoded: EmailSuppression = serde_json::from_value(suppression).unwrap(); + assert!(decoded.reason.is_none()); + assert!(decoded.bounce_type.is_none()); + + let mut analytics = serde_json::to_value(sample_analytics()).unwrap(); + analytics["variant_name"] = json!(null); + let decoded: EmailAnalytics = serde_json::from_value(analytics).unwrap(); + assert!(decoded.variant_name.is_none()); + } +} From ffd489b30d1b1fb6dd1ece2852111ea4cd854a4c Mon Sep 17 00:00:00 2001 From: brite-side0 Date: Mon, 28 Sep 2026 10:57:23 +0000 Subject: [PATCH 3/3] fix: #1506 Bound blockchain event replay to prevent unbounded RPC pagina Closes #1506 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 830788bd..9a8c6ed3 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,6 @@ - #1534: Add regression test ensuring metrics.rs worker_status gauges reset correctly after a worker restart + + +- #1506: Bound blockchain event replay to prevent unbounded RPC pagination