diff --git a/crates/titen-api/src/openapi.rs b/crates/titen-api/src/openapi.rs index c700f5f..c5a8813 100644 --- a/crates/titen-api/src/openapi.rs +++ b/crates/titen-api/src/openapi.rs @@ -79,6 +79,7 @@ use titen_core::models::{ crate::routes::comments::fetch_comments, crate::routes::comments::get_sentiment, crate::routes::comments::update_reply_status, + crate::routes::comments::delete_comment, crate::routes::comments::reply_to_comment, // Analytics crate::routes::analytics::list_analytics, diff --git a/crates/titen-api/src/routes/comments.rs b/crates/titen-api/src/routes/comments.rs index 472fbec..f34fd80 100644 --- a/crates/titen-api/src/routes/comments.rs +++ b/crates/titen-api/src/routes/comments.rs @@ -400,3 +400,48 @@ pub async fn reply_to_comment( )), } } + +/// Delete a comment row from the local store (admin cleanup, #267). +/// +/// DB-local by design — this endpoint never calls the Threads API. +#[utoipa::path( + delete, + path = "/api/comments/{id}", + tag = "comments", + params( + ("id" = String, Path, description = "Comment ID"), + ), + responses( + (status = 204, description = "Comment deleted"), + (status = 404, description = "Comment not found", body = serde_json::Value), + ), + security(("api_key" = [])), +)] +pub async fn delete_comment( + State(state): State, + Path(comment_id): Path, +) -> Result)> { + match state.store.delete_comment(&comment_id).await { + Ok(()) => Ok(StatusCode::NO_CONTENT), + Err(e) => { + let msg = e.to_string(); + if msg.contains("not found") { + Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": msg, + "code": "COMMENT_NOT_FOUND" + })), + )) + } else { + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": msg, + "code": "DELETE_FAILED" + })), + )) + } + } + } +} diff --git a/crates/titen-api/src/server.rs b/crates/titen-api/src/server.rs index 0e8589d..4bece2d 100644 --- a/crates/titen-api/src/server.rs +++ b/crates/titen-api/src/server.rs @@ -430,7 +430,7 @@ pub async fn serve( ) .route( "/api/comments/{id}", - patch(routes::comments::update_reply_status), + patch(routes::comments::update_reply_status).delete(routes::comments::delete_comment), ) .route( "/api/comments/{id}/reply", diff --git a/crates/titen-api/tests/api_comments.rs b/crates/titen-api/tests/api_comments.rs new file mode 100644 index 0000000..3170aef --- /dev/null +++ b/crates/titen-api/tests/api_comments.rs @@ -0,0 +1,91 @@ +mod common; + +use axum::body::Body; +use common::{body_to_json, send, test_app, test_pool, test_state}; +use titen_core::Store; + +async fn seed_comment(pool: &sqlx::SqlitePool, comment_id: &str) { + let store = Store::new(pool.clone()); + let (account, _) = store + .upsert_account(&titen_core::models::CreateAccount { + username: Some("deltest".to_string()), + user_id: Some("user_del".to_string()), + access_token: "FAKE_TEST_TOKEN".to_string(), + expires_at: "2099-12-31T00:00:00Z".to_string(), + app_id: None, + app_secret: None, + }) + .await + .expect("seed account"); + + store + .create_post( + "post-del", + &titen_core::models::CreatePost { + account_id: account.id.clone(), + media_type: Some("TEXT".to_string()), + caption: Some("delete endpoint test".to_string()), + text_attachment: None, + image_url: None, + video_url: None, + image_urls: None, + media_ids: None, + alt_text: None, + reply_to_id: None, + }, + ) + .await + .expect("seed post"); + + store + .insert_comment( + comment_id, + "post-del", + Some("tc-del-1"), + Some("someone"), + None, + "comment to delete", + ) + .await + .expect("seed comment"); +} + +#[tokio::test] +async fn delete_comment_returns_204_and_removes_row() { + let pool = test_pool().await; + let state = test_state(pool.clone()); + let app = test_app(state); + seed_comment(&pool, "c-route-del").await; + + let req = axum::http::Request::builder() + .method("DELETE") + .uri("/api/comments/c-route-del") + .body(Body::empty()) + .unwrap(); + let resp = send(req, &app).await; + assert_eq!(resp.status(), 204); + + let store = Store::new(pool.clone()); + assert!( + store.get_comment("c-route-del").await.is_err(), + "row must be gone from the store" + ); +} + +#[tokio::test] +async fn delete_missing_comment_returns_404() { + let pool = test_pool().await; + let state = test_state(pool.clone()); + let app = test_app(state); + + let req = axum::http::Request::builder() + .method("DELETE") + .uri("/api/comments/does-not-exist") + .body(Body::empty()) + .unwrap(); + let resp = send(req, &app).await; + assert_eq!(resp.status(), 404); + + let body = body_to_json(resp).await; + assert_eq!(body["code"], "COMMENT_NOT_FOUND"); +} diff --git a/crates/titen-api/tests/common/mod.rs b/crates/titen-api/tests/common/mod.rs index 3bc859f..f42ace5 100644 --- a/crates/titen-api/tests/common/mod.rs +++ b/crates/titen-api/tests/common/mod.rs @@ -58,6 +58,11 @@ pub fn test_app(state: AppState) -> Router { "/api/threads/{bundle_id}", get(routes::threads_bundle::get_thread_bundle), ) + .route( + "/api/comments/{id}", + axum::routing::patch(routes::comments::update_reply_status) + .delete(routes::comments::delete_comment), + ) .route( "/api/accounts/{id}", put(routes::accounts::update_account).delete(routes::accounts::delete_account), @@ -99,6 +104,8 @@ pub async fn send(req: axum::http::Request, app: &Router) -> axum::http::R /// Helper: create a test account via the Store directly (avoids Threads API calls). /// Returns the account JSON from the list endpoint. +// Shared across test binaries; not every binary uses it. +#[allow(dead_code)] pub async fn create_test_account(app: &Router, pool: &SqlitePool) -> Value { let store = Store::new(pool.clone()); let input = titen_core::models::CreateAccount { diff --git a/crates/titen-api/tests/store_comments.rs b/crates/titen-api/tests/store_comments.rs index 637d81d..705ee24 100644 --- a/crates/titen-api/tests/store_comments.rs +++ b/crates/titen-api/tests/store_comments.rs @@ -401,3 +401,32 @@ async fn cleanup_respects_author_guard() { assert_eq!(purged, 0, "different-author twin must not supersede"); assert!(store.get_comment("alice-1").await.is_ok()); } + +#[tokio::test] +async fn delete_comment_removes_row() { + let pool = pool().await; + let store = titen_core::Store::new(pool.clone()); + + store + .insert_comment("c-del", "post-1", Some("tc-d"), Some("alice"), None, "bye") + .await + .expect("seed"); + + store.delete_comment("c-del").await.expect("delete"); + assert!(store.get_comment("c-del").await.is_err()); +} + +#[tokio::test] +async fn delete_missing_comment_is_not_found() { + let pool = pool().await; + let store = titen_core::Store::new(pool.clone()); + + let err = store + .delete_comment("does-not-exist") + .await + .expect_err("must be CommentNotFound"); + assert!( + err.to_string().contains("not found"), + "unexpected error: {err}" + ); +} diff --git a/crates/titen-core/src/store.rs b/crates/titen-core/src/store.rs index f5e5f68..c4e660d 100644 --- a/crates/titen-core/src/store.rs +++ b/crates/titen-core/src/store.rs @@ -1704,6 +1704,22 @@ impl Store { Ok(result.rows_affected()) } + /// Delete a comment row from the local store (admin cleanup, #267). + /// + /// DB-local by design: this never calls the Threads API. Deleting someone + /// else's comment on Threads is not possible via the Graph API anyway; + /// moderation there is covered by the hide/unhide endpoints. + pub async fn delete_comment(&self, id: &str) -> Result<()> { + let result = sqlx::query("DELETE FROM comments WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(TitenError::CommentNotFound(id.to_string())); + } + Ok(()) + } + /// Update comment reply status and optionally store reply text. pub async fn update_comment_reply( &self,