Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/titen-api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions crates/titen-api/src/routes/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
Path(comment_id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
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"
})),
))
}
}
}
}
2 changes: 1 addition & 1 deletion crates/titen-api/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions crates/titen-api/tests/api_comments.rs
Original file line number Diff line number Diff line change
@@ -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");
}
7 changes: 7 additions & 0 deletions crates/titen-api/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -99,6 +104,8 @@ pub async fn send(req: axum::http::Request<Body>, 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 {
Expand Down
29 changes: 29 additions & 0 deletions crates/titen-api/tests/store_comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
16 changes: 16 additions & 0 deletions crates/titen-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading