feat: chat conversation list titles, sorting, etc

This commit is contained in:
Per Stark
2025-04-15 16:34:44 +02:00
parent d4f097ab98
commit 5af217170c
9 changed files with 309 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
use surrealdb::opt::PatchOp;
use uuid::Uuid;
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
@@ -46,6 +47,31 @@ impl Conversation {
Ok((conversation, messages))
}
pub async fn patch_title(
id: &str,
user_id: &str,
new_title: &str,
db: &SurrealDbClient,
) -> Result<(), AppError> {
// First verify ownership by getting conversation user_id
let conversation: Option<Conversation> = db.get_item(id).await?;
let conversation =
conversation.ok_or_else(|| AppError::NotFound("Conversation not found".to_string()))?;
if conversation.user_id != user_id {
return Err(AppError::Auth(
"Unauthorized to update this conversation".to_string(),
));
}
let _updated: Option<Self> = db
.update((Self::table_name(), id))
.patch(PatchOp::replace("/title", new_title.to_string()))
.patch(PatchOp::replace("/updated_at", Utc::now()))
.await?;
Ok(())
}
}
#[cfg(test)]
@@ -141,6 +167,85 @@ mod tests {
}
}
#[tokio::test]
async fn test_patch_title_success() {
let namespace = "test_ns";
let database = &Uuid::new_v4().to_string();
let db = SurrealDbClient::memory(namespace, database)
.await
.expect("Failed to start in-memory surrealdb");
let user_id = "user_1";
let original_title = "Original Title";
let conversation = Conversation::new(user_id.to_string(), original_title.to_string());
let conversation_id = conversation.id.clone();
db.store_item(conversation)
.await
.expect("Failed to store conversation");
let new_title = "Updated Title";
// Patch title successfully
let result = Conversation::patch_title(&conversation_id, user_id, new_title, &db).await;
assert!(result.is_ok());
// Retrieve from DB to verify
let updated_conversation = db
.get_item::<Conversation>(&conversation_id)
.await
.expect("Failed to get conversation")
.expect("Conversation missing");
assert_eq!(updated_conversation.title, new_title);
assert_eq!(updated_conversation.user_id, user_id);
}
#[tokio::test]
async fn test_patch_title_not_found() {
let namespace = "test_ns";
let database = &Uuid::new_v4().to_string();
let db = SurrealDbClient::memory(namespace, database)
.await
.expect("Failed to start in-memory surrealdb");
// Try to patch non-existing conversation
let result = Conversation::patch_title("nonexistent", "user_x", "New Title", &db).await;
assert!(result.is_err());
match result {
Err(AppError::NotFound(_)) => {}
_ => panic!("Expected NotFound error"),
}
}
#[tokio::test]
async fn test_patch_title_unauthorized() {
let namespace = "test_ns";
let database = &Uuid::new_v4().to_string();
let db = SurrealDbClient::memory(namespace, database)
.await
.expect("Failed to start in-memory surrealdb");
let owner_id = "owner";
let other_user_id = "intruder";
let conversation = Conversation::new(owner_id.to_string(), "Private".to_string());
let conversation_id = conversation.id.clone();
db.store_item(conversation)
.await
.expect("Failed to store conversation");
// Attempt patch with unauthorized user
let result =
Conversation::patch_title(&conversation_id, other_user_id, "Hacked Title", &db).await;
assert!(result.is_err());
match result {
Err(AppError::Auth(_)) => {}
_ => panic!("Expected Auth error"),
}
}
#[tokio::test]
async fn test_get_complete_conversation_with_messages() {
// Setup in-memory database for testing

View File

@@ -580,7 +580,7 @@ mod tests {
// Should fail with FileNotFound error
assert!(result.is_err());
match result {
Err(FileError::FileNotFound(_)) => {
Err(AppError::File(_)) => {
// Expected error
}
_ => panic!("Expected FileNotFound error"),

View File

@@ -96,6 +96,8 @@ impl IngestionPayload {
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
// Create a mock FileInfo for testing

View File

@@ -421,7 +421,10 @@ impl User {
) -> Result<Vec<Conversation>, AppError> {
let conversations: Vec<Conversation> = db
.client
.query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id")
.query(
"SELECT * FROM type::table($table_name) WHERE user_id = $user_id
ORDER BY updated_at DESC",
)
.bind(("table_name", Conversation::table_name()))
.bind(("user_id", user_id.to_string()))
.await?
@@ -775,4 +778,41 @@ mod tests {
let updated_user = updated_user.unwrap();
assert_eq!(updated_user.timezone, new_timezone);
}
#[tokio::test]
async fn test_conversations_order() {
let db = setup_test_db().await;
let user_id = "user_order_test";
// Create conversations with varying updated_at timestamps
let mut conversations = Vec::new();
for i in 0..5 {
let mut conv = Conversation::new(user_id.to_string(), format!("Conv {}", i));
// Fake updated_at i minutes apart
conv.updated_at = chrono::Utc::now() - chrono::Duration::minutes(i);
db.store_item(conv.clone())
.await
.expect("Failed to store conversation");
conversations.push(conv);
}
// Retrieve via get_user_conversations - should be ordered by updated_at DESC
let retrieved = User::get_user_conversations(user_id, &db)
.await
.expect("Failed to get conversations");
assert_eq!(retrieved.len(), conversations.len());
for window in retrieved.windows(2) {
// Assert each earlier conversation has updated_at >= later conversation
assert!(
window[0].updated_at >= window[1].updated_at,
"Conversations not ordered descending by updated_at"
);
}
// (Optional) Check first conversation title matches the most recently updated
let most_recent = conversations.iter().max_by_key(|c| c.updated_at).unwrap();
assert_eq!(retrieved[0].id, most_recent.id);
}
}