diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 8bafdf17..4812f72d 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -33,6 +33,7 @@ | Method | Path | Operation | Auth | |--------|------|-----------|------| | POST | `/api/v1/markets/{market_id}/resolve` | `resolveMarket` | ApiKeyAuth | +| POST | `/api/v1/markets/{market_id}/cancel` | `cancelMarket` | ApiKeyAuth | | POST | `/api/blockchain/replay` | `blockchainReplay` | ApiKeyAuth | | GET | `/api/v1/email/preview/{template_name}` | `emailPreview` | ApiKeyAuth | | POST | `/api/v1/email/test` | `emailSendTest` | ApiKeyAuth | diff --git a/frontend/src/app/markets/[marketId]/cancel/page.tsx b/frontend/src/app/markets/[marketId]/cancel/page.tsx index b456c2ad..74de313f 100644 --- a/frontend/src/app/markets/[marketId]/cancel/page.tsx +++ b/frontend/src/app/markets/[marketId]/cancel/page.tsx @@ -1,15 +1,14 @@ 'use client'; import { use, useEffect, useState } from 'react'; -import { api } from '@/lib/api/public-client'; -import { getEnvConfig } from '@/lib/env'; +import { api, ApiError, getContractErrorMessage } from '@/lib/api/admin-client'; import { useWalletAddress } from '@/lib/hooks/useWalletAddress'; import { canCancelMarket, parseMarketView, type MarketView } from '@/lib/markets/marketState'; import { Modal } from '@/components/Modal'; import './cancel.css'; /** - * Market cancellation flow (#1376). + * Market cancellation flow (#1376, #1754). * * `contracts/predict-iq/src/test_cancellation.rs` defines when cancellation * is contractually valid: only the creator/admin, only while `Active`, never @@ -17,35 +16,13 @@ import './cancel.css'; * (`lib/markets/marketState.ts::canCancelMarket`) so the cancel action is * disabled/hidden instead of failing after a wallet has already signed. * - * There is no `POST /api/v1/markets/{id}/cancel` route in - * `services/api/openapi.yaml` yet (only `.../resolve` exists today), so the - * submit call below is a plain `fetch` rather than a `lib/api/*-client.ts` - * method — see the same note on `DepositTierBadge.tsx`. Swap it for the - * typed client once that endpoint is generated into `schema.d.ts`. + * `POST /api/v1/markets/{market_id}/cancel` is defined in + * `services/api/openapi.yaml` and typed as `api.cancelMarket` in + * `lib/api/admin-client.ts`. */ type SubmitState = 'idle' | 'submitting' | 'success' | 'error'; -async function cancelMarket(marketId: string, signal: AbortSignal): Promise { - const base = getEnvConfig().NEXT_PUBLIC_API_URL.replace(/\/$/, ''); - const res = await fetch(`${base}/api/v1/markets/${encodeURIComponent(marketId)}/cancel`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal, - }); - - if (!res.ok) { - let message = `Failed to cancel market (HTTP ${res.status})`; - try { - const body = (await res.json()) as { message?: string }; - if (body.message) message = body.message; - } catch { - // no JSON body — keep the generic message - } - throw new Error(message); - } -} - export default function CancelMarketPage({ params }: { params: Promise<{ marketId: string }> }) { const { marketId } = use(params); const { address: walletAddress } = useWalletAddress(); @@ -83,12 +60,16 @@ export default function CancelMarketPage({ params }: { params: Promise<{ marketI setSubmitState('submitting'); setSubmitError(null); try { - await cancelMarket(marketId, new AbortController().signal); + await api.cancelMarket(marketId); setSubmitState('success'); setConfirmOpen(false); } catch (err) { setSubmitState('error'); - setSubmitError(err instanceof Error ? err.message : 'Failed to cancel market.'); + if (err instanceof ApiError && err.code === 'CONTRACT_ERROR' && typeof err.details?.['contract_code'] === 'number') { + setSubmitError(getContractErrorMessage(err.details['contract_code'] as number)); + } else { + setSubmitError(err instanceof Error ? err.message : 'Failed to cancel market.'); + } } }; diff --git a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts index e5c8bbca..d10b434f 100644 --- a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts +++ b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts @@ -114,6 +114,12 @@ describe('API client paths match schema.d.ts (contract test, #3)', () => { params: { market_id: '42' }, call: () => adminApi.resolveMarket('42'), }, + { + name: 'cancelMarket', + template: '/api/v1/markets/{market_id}/cancel', + params: { market_id: '42' }, + call: () => adminApi.cancelMarket('42'), + }, ]; it('every path used by the client is declared in schema.d.ts', () => { diff --git a/frontend/src/lib/api/__tests__/client-schema-type-contract.test.ts b/frontend/src/lib/api/__tests__/client-schema-type-contract.test.ts index 5b3db6cb..d6a8c633 100644 --- a/frontend/src/lib/api/__tests__/client-schema-type-contract.test.ts +++ b/frontend/src/lib/api/__tests__/client-schema-type-contract.test.ts @@ -38,6 +38,7 @@ type _UserBets = AssertPath<'/api/v1/blockchain/users/{user}/bets'>; type _OracleResult = AssertPath<'/api/v1/blockchain/oracle/{market_id}'>; type _TransactionStatus = AssertPath<'/api/v1/blockchain/tx/{tx_hash}'>; type _ResolveMarket = AssertPath<'/api/v1/markets/{market_id}/resolve'>; +type _CancelMarket = AssertPath<'/api/v1/markets/{market_id}/cancel'>; // Deliberately-wrong path proving the check has teeth. Uncommenting this // line fails `tsc --noEmit` with: diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index 8b0ef93b..aba05110 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -38,6 +38,7 @@ describe('API Client', () => { ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'POST', '/api/v1/newsletter/gdpr/export'], ['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'], ['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/v1/markets/3/resolve'], + ['cancelMarket', () => api.cancelMarket(3), 'POST', '/api/v1/markets/3/cancel'], ['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'], ['emailSendTest', () => api.emailSendTest({ recipient: 'a@b.com', template_name: 'welcome' }), 'POST', '/api/v1/email/test'], ['getEmailAnalytics', () => api.getEmailAnalytics({ days: 7 }), 'GET', '/api/v1/email/analytics'], @@ -753,6 +754,15 @@ describe('API Client', () => { ); }); + it('cancelMarket encodes a slash in marketId', async () => { + mockOk(); + await api.cancelMarket('10/20'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/v1/markets/10%2F20/cancel`, + expect.any(Object), + ); + }); + it('emailPreview encodes a slash in templateName', async () => { mockOk(); await api.emailPreview('welcome/v2'); @@ -847,6 +857,15 @@ describe('API Client', () => { ); }); + it('cancelMarket encodes a slash in marketId', async () => { + mockOk(); + await api.cancelMarket('10/20'); + expect(global.fetch).toHaveBeenCalledWith( + `${base}/api/v1/markets/10%2F20/cancel`, + expect.any(Object), + ); + }); + it('emailPreview encodes a slash in templateName', async () => { mockOk(); await api.emailPreview('welcome/v2'); diff --git a/frontend/src/lib/api/__tests__/public-client.test.ts b/frontend/src/lib/api/__tests__/public-client.test.ts index b650eec8..ac027c4a 100644 --- a/frontend/src/lib/api/__tests__/public-client.test.ts +++ b/frontend/src/lib/api/__tests__/public-client.test.ts @@ -39,6 +39,10 @@ describe('Module boundary: public-client must not expose admin surface (#1158)', expect((publicClientModule.api as Record)['resolveMarket']).toBeUndefined(); }); + it('does NOT include cancelMarket on the public api object', () => { + expect((publicClientModule.api as Record)['cancelMarket']).toBeUndefined(); + }); + it('does NOT include emailPreview on the public api object', () => { expect((publicClientModule.api as Record)['emailPreview']).toBeUndefined(); }); @@ -92,6 +96,10 @@ describe('Module boundary: public-client must not expose admin surface (#1158)', expect(typeof adminClientModule.api.resolveMarket).toBe('function'); }); + it('includes cancelMarket on the admin api object', () => { + expect(typeof adminClientModule.api.cancelMarket).toBe('function'); + }); + it('includes emailPreview on the admin api object', () => { expect(typeof adminClientModule.api.emailPreview).toBe('function'); }); diff --git a/frontend/src/lib/api/admin-client.ts b/frontend/src/lib/api/admin-client.ts index 0d5a0ae0..30d8abcc 100644 --- a/frontend/src/lib/api/admin-client.ts +++ b/frontend/src/lib/api/admin-client.ts @@ -37,6 +37,7 @@ type HttpMethod = "GET" | "POST" | "DELETE"; */ const PATHS = { resolveMarket: "/api/v1/markets/{market_id}/resolve", + cancelMarket: "/api/v1/markets/{market_id}/cancel", blockchainReplay: "/api/blockchain/replay", content: "/api/v1/content", auditLogs: "/api/v1/audit/logs", @@ -363,6 +364,13 @@ export const api = { { cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], signal }, ), + cancelMarket: (marketId: number | string, signal?: AbortSignal) => + request( + "POST", + fillPath(PATHS.cancelMarket, 'market_id', marketId), + { cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], signal }, + ), + getAuditLogs: (params: Record = {}, signal?: AbortSignal) => request("GET", PATHS.auditLogs, { params, cacheTtl: CACHE_TTL.SHORT, cacheTags: [CacheTag.AUDIT], signal, diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 4d41b1e4..6f84b61e 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -89,6 +89,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/markets/{market_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel a market and invalidate caches (admin) */ + post: operations["cancelMarket"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/blockchain/health": { parameters: { query?: never; @@ -740,6 +757,40 @@ export interface operations { 500: components["responses"]["ApiError"]; }; }; + cancelMarket: { + parameters: { + query?: never; + header?: { + /** + * @description Target API version. Defaults to the current stable version (`v1`). + * Responses for deprecated versions include `Deprecation`, `Sunset`, and `Link` headers. + */ + "API-Version"?: components["parameters"]["apiVersion"]; + }; + path: { + market_id: components["parameters"]["marketId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cache invalidation result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvalidationResult"]; + }; + }; + 400: components["responses"]["ApiError"]; + 401: components["responses"]["ApiError"]; + 403: components["responses"]["ApiError"]; + 404: components["responses"]["ApiError"]; + 429: components["responses"]["ApiError"]; + 500: components["responses"]["ApiError"]; + }; + }; getBlockchainHealth: { parameters: { query?: never; diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index f1c3ec7e..72e7d836 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -154,6 +154,36 @@ paths: "500": $ref: "#/components/responses/ApiError" + /api/v1/markets/{market_id}/cancel: + post: + tags: [markets] + operationId: cancelMarket + summary: Cancel a market and invalidate caches (admin) + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/marketId" + - $ref: "#/components/parameters/apiVersion" + responses: + "200": + description: Cache invalidation result + content: + application/json: + schema: + $ref: "#/components/schemas/InvalidationResult" + "400": + $ref: "#/components/responses/ApiError" + "401": + $ref: "#/components/responses/ApiError" + "403": + $ref: "#/components/responses/ApiError" + "404": + $ref: "#/components/responses/ApiError" + "429": + $ref: "#/components/responses/ApiError" + "500": + $ref: "#/components/responses/ApiError" + /api/v1/blockchain/health: get: tags: [blockchain] diff --git a/services/api/src/audit_middleware.rs b/services/api/src/audit_middleware.rs index 13da8432..6f2d18f9 100644 --- a/services/api/src/audit_middleware.rs +++ b/services/api/src/audit_middleware.rs @@ -240,6 +240,12 @@ fn parse_admin_action(path: &str, method: &axum::http::Method) -> (String, Strin .find_map(|s| s.parse::().ok()) .map(|id| id.to_string()); ("resolve_market".to_string(), "market".to_string(), market_id) + } else if path.contains("/markets/") && path.contains("/cancel") { + let market_id = path + .split('/') + .find_map(|s| s.parse::().ok()) + .map(|id| id.to_string()); + ("cancel_market".to_string(), "market".to_string(), market_id) } else if path.contains("/email/preview/") { let template = path.split('/').last().map(|s| s.to_string()); ("preview_email".to_string(), "email_template".to_string(), template) diff --git a/services/api/src/cache/mod.rs b/services/api/src/cache/mod.rs index 894bd976..6e11b06e 100644 --- a/services/api/src/cache/mod.rs +++ b/services/api/src/cache/mod.rs @@ -1124,6 +1124,16 @@ pub enum InvalidationTag { network: String, featured_limit: i64, }, + + /// A market was cancelled. + /// + /// Invalidates the per-market chain entry, the oracle result, and the + /// aggregate statistics / featured-markets lists. + MarketCancelled { + market_id: i64, + network: String, + featured_limit: i64, + }, } impl InvalidationTag { @@ -1134,6 +1144,11 @@ impl InvalidationTag { market_id, network, featured_limit, + } + | InvalidationTag::MarketCancelled { + market_id, + network, + featured_limit, } => vec![ keys::chain_market(*market_id), keys::chain_oracle_result(network, *market_id), diff --git a/services/api/src/cancel_market_tests.rs b/services/api/src/cancel_market_tests.rs new file mode 100644 index 00000000..55ddcc06 --- /dev/null +++ b/services/api/src/cancel_market_tests.rs @@ -0,0 +1,164 @@ +#[cfg(test)] +mod cancel_market_tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::post, + Router, + }; + use std::sync::Arc; + use tower::ServiceExt; + + use crate::handlers::{cancel_market, InvalidationResult}; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Build a minimal router wired to `cancel_market` for handler-level tests. + fn app(state: Arc) -> Router { + Router::new() + .route("/admin/markets/:market_id/cancel", post(cancel_market)) + .with_state(state) + } + + async fn post_cancel( + router: Router, + market_id: i64, + ) -> axum::response::Response { + router + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/markets/{market_id}/cancel")) + .header("content-type", "application/json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + // --------------------------------------------------------------------------- + // Unit tests — no real DB/Redis required + // --------------------------------------------------------------------------- + + /// Verifies that a successful cancel DB write returns 200 and a non-zero + /// `invalidated_keys` count. + #[tokio::test] + #[ignore] // Requires PostgreSQL + Redis + async fn test_cancel_market_success_returns_200_and_invalidation_count() { + let state = build_test_state().await; + // Insert a test market first so the UPDATE finds a row. + sqlx::query( + "INSERT INTO markets (id, title, status, total_volume, ends_at) \ + VALUES (9003, 'Cancel Test Market', 'active', 0, NOW() + INTERVAL '1 day')", + ) + .execute(state.db.pool()) + .await + .unwrap(); + + let response = post_cancel(app(Arc::clone(&state)), 9003).await; + assert_eq!(response.status(), StatusCode::OK); + + let body: InvalidationResult = + serde_json::from_slice(&axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(body.invalidated_keys > 0); + + // Cleanup + sqlx::query("DELETE FROM markets WHERE id = 9003") + .execute(state.db.pool()) + .await + .unwrap(); + } + + /// Verifies that cancelling a non-existent market returns 500. + #[tokio::test] + #[ignore] // Requires PostgreSQL + Redis + async fn test_cancel_market_not_found_returns_500() { + let state = build_test_state().await; + let response = post_cancel(app(Arc::clone(&state)), 999_999_999).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + /// Verifies that cancelling an already-cancelled market returns 500. + #[tokio::test] + #[ignore] // Requires PostgreSQL + Redis + async fn test_cancel_market_already_cancelled_returns_500() { + let state = build_test_state().await; + sqlx::query( + "INSERT INTO markets (id, title, status, total_volume, ends_at) \ + VALUES (9004, 'Cancelled Market', 'cancelled', 0, NOW() - INTERVAL '1 day')", + ) + .execute(state.db.pool()) + .await + .unwrap(); + + let response = post_cancel(app(Arc::clone(&state)), 9004).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + // Cleanup + sqlx::query("DELETE FROM markets WHERE id = 9004") + .execute(state.db.pool()) + .await + .unwrap(); + } + + // --------------------------------------------------------------------------- + // Pure-logic unit tests (no I/O) + // --------------------------------------------------------------------------- + + /// Verifies that `InvalidationResult` from cancel serialises correctly. + #[test] + fn test_cancel_invalidation_result_serialises() { + let result = InvalidationResult { invalidated_keys: 6, cache_invalidated: true }; + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["invalidated_keys"], 6); + assert_eq!(json["cache_invalidated"], true); + } + + // --------------------------------------------------------------------------- + // Helper — builds AppState from env (used by #[ignore] integration tests) + // --------------------------------------------------------------------------- + + #[cfg(test)] + async fn build_test_state() -> Arc { + use crate::{ + audit::AuditLogger, + blockchain::BlockchainClient, + cache::RedisCache, + config::Config, + db::Database, + email::{queue::EmailQueue, service::EmailService, webhook::WebhookHandler}, + metrics::Metrics, + newsletter::IpRateLimiter, + }; + + let config = Config::from_env(); + let metrics = Metrics::new().expect("metrics"); + let cache = RedisCache::new(&config.redis_url).await.expect("redis"); + let db = Database::new(&config.database_url, cache.clone(), metrics.clone(), &config.db_pool) + .await + .expect("db"); + let blockchain = BlockchainClient::new(&config, cache.clone(), db.clone(), metrics.clone()) + .expect("blockchain"); + let email_service = EmailService::new(config.clone()).expect("email_service"); + let email_queue = EmailQueue::new(cache.clone(), db.clone()); + let webhook_handler = WebhookHandler::new(db.clone(), cache.clone(), config.webhook_replay_window_secs); + let audit_logger = AuditLogger::new(db.pool()); + + Arc::new(crate::AppState { + config, + cache: cache.clone(), + db, + blockchain, + metrics, + newsletter_rate_limiter: IpRateLimiter::new(cache), + email_service, + email_queue, + webhook_handler, + audit_logger, + }) + } +} diff --git a/services/api/src/db.rs b/services/api/src/db.rs index a8e9569c..2250a9b7 100644 --- a/services/api/src/db.rs +++ b/services/api/src/db.rs @@ -789,6 +789,32 @@ impl Database { Ok(()) } + /// Cancel a market by setting its status to `cancelled`. + /// + /// Returns an error if the market does not exist or is not in `active` status. + pub async fn cancel_market(&self, market_id: i64) -> anyhow::Result<()> { + let rows_affected = self + .with_timeout( + "cancel_market", + sqlx::query( + "UPDATE markets \ + SET status = 'cancelled' \ + WHERE id = $1 AND status = 'active' AND deleted_at IS NULL", + ) + .bind(market_id) + .execute(&self.pool), + ) + .await + .map_err(anyhow::Error::from)? + .rows_affected(); + + if rows_affected == 0 { + anyhow::bail!("market {market_id} not found or not in active status"); + } + + Ok(()) + } + /// Ping the database with a bounded timeout. Returns Ok(()) if reachable. pub async fn ping(&self) -> anyhow::Result<()> { tokio::time::timeout( diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index 314bc8a3..e92a9749 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -1201,6 +1201,60 @@ pub async fn resolve_market( )) } +#[utoipa::path( + post, + path = "/api/v1/markets/{market_id}/cancel", + tag = "markets", + params( + ("market_id" = i64, Path, description = "Market database ID"), + ), + responses( + (status = 200, description = "Market cancelled and cache invalidated", body = InvalidationResult), + (status = 400, description = "Bad request", body = ApiError), + (status = 500, description = "Internal error", body = ApiError), + ), + security(("api_key" = [])) +)] +pub async fn cancel_market( + State(state): State>, + Path(market_id): Path, +) -> Result { + // 1. Persist the cancellation to the database. + state + .db + .cancel_market(market_id) + .await + .map_err(into_api_error)?; + + // 2. Invalidate only the keys affected by this market's cancellation via tag. + let tag = InvalidationTag::MarketCancelled { + market_id, + network: state.config.network_name().to_owned(), + featured_limit: state.config.featured_limit, + }; + + let (invalidated, cache_invalidated) = match state.cache.invalidate_tag(&tag).await { + Ok(count) => { + state.metrics.observe_invalidation("market_cancel", count); + tracing::info!(market_id, count, "market cancelled and cache invalidated"); + (count, true) + } + Err(e) => { + tracing::warn!(market_id, error = %e, "cache invalidation failed after successful cancel DB write"); + state.metrics.observe_invalidation("market_cancel", 0); + (0, false) + } + }; + + Ok(( + StatusCode::OK, + Json(InvalidationResult { + invalidated_keys: invalidated, + cache_invalidated, + }), + )) +} + pub async fn metrics(State(state): State>) -> Result { state.db.record_pool_metrics(); let body = state.metrics.render().map_err(into_api_error)?; diff --git a/services/api/src/lib.rs b/services/api/src/lib.rs index 93c7b3d1..8b0f19e6 100644 --- a/services/api/src/lib.rs +++ b/services/api/src/lib.rs @@ -8,6 +8,8 @@ pub mod cors; pub mod csrf; #[cfg(test)] mod resolve_market_tests; +#[cfg(test)] +mod cancel_market_tests; pub mod blockchain; pub mod cache; pub mod compression; diff --git a/services/api/src/main.rs b/services/api/src/main.rs index b6af3562..90169026 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -411,6 +411,10 @@ async fn main() -> anyhow::Result<()> { "/api/v1/markets/:market_id/resolve", post(handlers::resolve_market), ) + .route( + "/api/v1/markets/:market_id/cancel", + post(handlers::cancel_market), + ) .route( "/api/blockchain/replay", post(handlers::blockchain_replay), diff --git a/services/api/src/openapi_spec.rs b/services/api/src/openapi_spec.rs index 25bbeaa0..1fb90fb5 100644 --- a/services/api/src/openapi_spec.rs +++ b/services/api/src/openapi_spec.rs @@ -35,6 +35,7 @@ use crate::pagination::PaginationQuery; crate::handlers::featured_markets, crate::handlers::content, crate::handlers::resolve_market, + crate::handlers::cancel_market, crate::handlers::blockchain_health, crate::handlers::blockchain_market_data, crate::handlers::blockchain_platform_stats, diff --git a/services/api/tests/audit_middleware_tests.rs b/services/api/tests/audit_middleware_tests.rs index 13160420..5550a25b 100644 --- a/services/api/tests/audit_middleware_tests.rs +++ b/services/api/tests/audit_middleware_tests.rs @@ -117,6 +117,12 @@ mod tests { .find_map(|s| s.parse::().ok()) .map(|id| id.to_string()); ("resolve_market".to_string(), "market".to_string(), market_id) + } else if path.contains("/markets/") && path.contains("/cancel") { + let market_id = path + .split('/') + .find_map(|s| s.parse::().ok()) + .map(|id| id.to_string()); + ("cancel_market".to_string(), "market".to_string(), market_id) } else if path.contains("/audit/logs") { ("query_audit_logs".to_string(), "audit_log".to_string(), None) } else { diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs index cc4b6b85..0bd8ef9d 100644 --- a/services/api/tests/openapi_contract_test.rs +++ b/services/api/tests/openapi_contract_test.rs @@ -18,6 +18,7 @@ mod tests { ("GET", "/api/v1/markets/featured"), ("GET", "/api/v1/content"), ("POST", "/api/v1/markets/{market_id}/resolve"), + ("POST", "/api/v1/markets/{market_id}/cancel"), ("GET", "/api/v1/blockchain/health"), ("GET", "/api/v1/blockchain/markets/{market_id}"), ("GET", "/api/v1/blockchain/stats"), @@ -44,6 +45,7 @@ mod tests { /// Admin routes that must declare ApiKeyAuth security in the spec. const ADMIN_ROUTES: &[(&str, &str)] = &[ ("POST", "/api/v1/markets/{market_id}/resolve"), + ("POST", "/api/v1/markets/{market_id}/cancel"), ("GET", "/api/v1/email/preview/{template_name}"), ("POST", "/api/v1/email/test"), ("GET", "/api/v1/email/analytics"), @@ -170,6 +172,7 @@ mod tests { fn admin_routes_have_security_in_spec() { let admin_operation_ids = [ "resolveMarket", + "cancelMarket", "emailPreview", "emailSendTest", "getEmailAnalytics",