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
3 changes: 3 additions & 0 deletions crates/titen-api/src/routes/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,14 @@ pub async fn fetch_comments(
let mut stored = Vec::new();
for cd in &comment_data {
let id = Uuid::now_v7().to_string();
// Meta returns "" for missing ids under standard access — treat as None.
let tcid = Some(cd.threads_comment_id.as_str()).filter(|s| !s.is_empty());
match state
.store
.insert_comment(
&id,
&post_id,
tcid,
cd.author_username.as_deref(),
cd.author_user_id.as_deref(),
&cd.text,
Expand Down
250 changes: 250 additions & 0 deletions crates/titen-api/tests/store_comments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
//! #261 — comment persistence: threads_comment_id stored, dedup by id,
//! text-fallback dedup for id-less rows (Meta standard access omits id/from),
//! and in-place attribution backfill.

use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use std::str::FromStr;

async fn pool() -> SqlitePool {
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("in-memory sqlite");
let pool = SqlitePoolOptions::new()
.max_connections(1) // single connection = single in-memory DB
.connect_with(options)
.await
.expect("pool");
let store = titen_core::Store::new(pool.clone());
store.migrate().await.expect("migrations");
seed_post(&store, &pool).await;
pool
}

async fn seed_post(store: &titen_core::Store, pool: &SqlitePool) {
sqlx::query(
"INSERT INTO accounts (id, username, user_id, access_token, expires_at, is_active, created_at, updated_at) \
VALUES ('acc-1', 'cmttest', '123654', 'FAKE_TEST_TOKEN', '2026-12-01T00:00:00+00:00', 1, datetime('now'), datetime('now')) \
ON CONFLICT(id) DO NOTHING",
)
.execute(pool)
.await
.expect("seed account");

let input = titen_core::models::CreatePost {
account_id: "acc-1".into(),
media_type: Some("CAROUSEL".into()),
caption: Some("post under test".into()),
text_attachment: None,
image_url: None,
video_url: None,
image_urls: None,
media_ids: None,
alt_text: None,
reply_to_id: None,
};
store
.create_post("post-1", &input)
.await
.expect("seed post");
}

#[tokio::test]
async fn threads_comment_id_is_persisted() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

let c = store
.insert_comment(
"c1",
"post-1",
Some("17891234567890123"),
Some("alice"),
Some("u1"),
"hello",
)
.await
.expect("insert");

assert_eq!(c.threads_comment_id.as_deref(), Some("17891234567890123"));
assert_eq!(c.author_username.as_deref(), Some("alice"));
}

#[tokio::test]
async fn same_threads_id_dedups_to_one_row() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

let a = store
.insert_comment("c1", "post-1", Some("tc-1"), Some("alice"), None, "hello")
.await
.expect("first");
let b = store
.insert_comment("c2", "post-1", Some("tc-1"), Some("alice"), None, "hello")
.await
.expect("second (dup)");

assert_eq!(
a.id, b.id,
"re-fetch of the same comment must reuse the row"
);
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comments WHERE post_id = 'post-1'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count.0, 1);
}

#[tokio::test]
async fn idless_comments_dedup_by_text() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

let a = store
.insert_comment("c1", "post-1", None, None, None, "anonymous question")
.await
.expect("first");
let b = store
.insert_comment("c2", "post-1", None, None, None, "anonymous question")
.await
.expect("second (dup)");

assert_eq!(a.id, b.id, "id-less re-fetch must dedup by (post_id, text)");
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comments WHERE post_id = 'post-1'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count.0, 1);
}

#[tokio::test]
async fn attribution_backfill_fills_legacy_row_in_place() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

// Legacy fetch: Meta returned no id/from (standard access).
let legacy = store
.insert_comment("c1", "post-1", None, None, None, "How does supersede work?")
.await
.expect("legacy row");
assert_eq!(legacy.threads_comment_id, None);

// Later fetch (or enrichment): id + author now known, same text.
let backfilled = store
.insert_comment(
"c2",
"post-1",
Some("tc-backfill"),
Some("taufikabayy"),
Some("user_9"),
"How does supersede work?",
)
.await
.expect("backfilled");

assert_eq!(
backfilled.id, legacy.id,
"backfill must update in place, not insert"
);
assert_eq!(
backfilled.threads_comment_id.as_deref(),
Some("tc-backfill")
);
assert_eq!(backfilled.author_username.as_deref(), Some("taufikabayy"));
assert_eq!(backfilled.author_user_id.as_deref(), Some("user_9"));

let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comments WHERE post_id = 'post-1'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count.0, 1);
}

#[tokio::test]
async fn different_idless_comments_are_not_collapsed() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

store
.insert_comment("c1", "post-1", None, None, None, "first question")
.await
.expect("first");
store
.insert_comment("c2", "post-1", None, None, None, "second question")
.await
.expect("second");

let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comments WHERE post_id = 'post-1'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count.0, 2, "distinct texts must stay distinct rows");
}

#[tokio::test]
async fn same_text_from_different_authors_stays_distinct() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

// Meta omits id AND author for both — but authors were known on earlier
// fetches. Identical text from different users must NOT collapse.
store
.insert_comment("c1", "post-1", None, Some("alice"), None, "Great post!")
.await
.expect("alice row");
store
.insert_comment("c2", "post-1", None, Some("bob"), None, "Great post!")
.await
.expect("bob row");

let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comments WHERE post_id = 'post-1'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count.0, 2, "same text + different authors = distinct rows");
}

#[tokio::test]
async fn backfill_never_grafts_onto_another_authors_row() {
let pool = pool().await;
let store = titen_core::Store::new(pool.clone());

// alice's id-less comment…
let alice = store
.insert_comment(
"c1",
"post-1",
None,
Some("alice"),
None,
"How does supersede work?",
)
.await
.expect("alice row");

// …then the SAME text arrives attributed to taufikabayy (a different
// person who coincidentally wrote the same words). It must become its
// own row, NOT graft the id onto alice's row.
let other = store
.insert_comment(
"c2",
"post-1",
Some("tc-x"),
Some("taufikabayy"),
Some("user_9"),
"How does supersede work?",
)
.await
.expect("taufik row");

assert_ne!(alice.id, other.id);
assert_eq!(
alice.threads_comment_id, None,
"alice's row must stay untouched"
);
assert_eq!(other.threads_comment_id.as_deref(), Some("tc-x"));

let alice_after = store.get_comment(&alice.id).await.expect("reload");
assert_eq!(alice_after.author_username.as_deref(), Some("alice"));
assert_eq!(alice_after.threads_comment_id, None);
}
2 changes: 1 addition & 1 deletion crates/titen-api/tests/store_timestamps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ async fn comment_insert_fetched_at_is_canonical() {
.expect("create post");

store
.insert_comment("cmt-t1", &post.id, Some("tester"), None, "nice")
.insert_comment("cmt-t1", &post.id, None, Some("tester"), None, "nice")
.await
.expect("insert comment");

Expand Down
87 changes: 86 additions & 1 deletion crates/titen-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1519,24 +1519,109 @@ impl Store {
.map_err(Into::into)
}

/// Insert a fetched comment, with dedup (#261).
///
/// Natural key: `threads_comment_id` when Meta returns one. Meta's
/// standard-access `/replies` may omit `id`/`from` for third-party
/// commenters — in that case dedup falls back to `(post_id, text)` among
/// rows that also lack a Threads id, so re-fetches no longer duplicate
/// rows. When an id-less row later gets attributed (backfill case), the
/// existing row is updated in place: threads_comment_id/author fields are
/// filled via COALESCE, fetched_at is bumped, and the row id (plus its
/// sentiment/reply state) is preserved.
pub async fn insert_comment(
&self,
id: &str,
post_id: &str,
threads_comment_id: Option<&str>,
author_username: Option<&str>,
author_user_id: Option<&str>,
text: &str,
) -> Result<Comment> {
let existing: Option<Comment> = if let Some(tcid) = threads_comment_id {
// Exact id match first.
let exact = sqlx::query_as::<_, Comment>(
"SELECT * FROM comments WHERE post_id = ?1 AND threads_comment_id = ?2 LIMIT 1",
)
.bind(post_id)
.bind(tcid)
.fetch_optional(&self.pool)
.await?;
if exact.is_some() {
exact
} else {
// Attribution backfill (#261): a legacy id-less row with the
// same text may be enriched with the incoming id/author.
// Restricted to same-author or still-anonymous rows so an
// unrelated commenter's row can never absorb the id, and the
// most recently fetched candidate wins.
sqlx::query_as::<_, Comment>(
"SELECT * FROM comments \
WHERE post_id = ?1 AND text = ?2 AND threads_comment_id IS NULL \
AND (author_username IS ?3 OR author_username IS NULL) \
ORDER BY (author_username IS NULL) ASC, fetched_at DESC LIMIT 1",
)
.bind(post_id)
.bind(text)
.bind(author_username)
.fetch_optional(&self.pool)
.await?
}
} else {
// Id-less incoming (Meta omitted id/from): dedup only against
// id-less rows with the same text AND the same (possibly absent)
// author — two different users posting identical text stay
// distinct rows.
sqlx::query_as::<_, Comment>(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"SELECT * FROM comments \
WHERE post_id = ?1 AND text = ?2 AND threads_comment_id IS NULL \
AND author_username IS ?3",
)
.bind(post_id)
.bind(text)
.bind(author_username)
.fetch_optional(&self.pool)
.await?
};

// Explicit canonical fetched_at so the row never depends on the
// space-format schema default, which breaks RFC3339 comparisons.
let fetched_at = chrono::Utc::now()
.format(crate::time::CANONICAL_FMT)
.to_string();

if let Some(prev) = existing {
// Refresh attribution + freshness; keep the original row id,
// sentiment, and reply state.
sqlx::query(
"UPDATE comments SET \
threads_comment_id = COALESCE(?1, threads_comment_id), \
author_username = COALESCE(?2, author_username), \
author_user_id = COALESCE(?3, author_user_id), \
fetched_at = ?4 \
WHERE id = ?5",
)
.bind(threads_comment_id)
.bind(author_username)
.bind(author_user_id)
.bind(&fetched_at)
.bind(&prev.id)
.execute(&self.pool)
.await?;

return sqlx::query_as::<_, Comment>("SELECT * FROM comments WHERE id = ?")
.bind(&prev.id)
.fetch_one(&self.pool)
.await
.map_err(Into::into);
}

sqlx::query(
"INSERT INTO comments (id, post_id, author_username, author_user_id, text, fetched_at) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO comments (id, post_id, threads_comment_id, author_username, author_user_id, text, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(post_id)
.bind(threads_comment_id)
.bind(author_username)
.bind(author_user_id)
.bind(text)
Expand Down
3 changes: 3 additions & 0 deletions crates/titen-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,10 +768,13 @@ fn handle_tool_call(
let mut stored = Vec::new();
for cd in &comment_data {
let id = uuid::Uuid::now_v7().to_string();
// Meta returns "" for missing ids under standard access (#261).
let tcid = Some(cd.threads_comment_id.as_str()).filter(|s| !s.is_empty());
match store
.insert_comment(
&id,
post_id,
tcid,
cd.author_username.as_deref(),
cd.author_user_id.as_deref(),
&cd.text,
Expand Down
Loading
Loading