use rusqlite::{params, Connection};
use crate::db::models::Comment;
use crate::error::LificError;
use super::unescape_text;
pub fn create_comment(
conn: &Connection,
issue_id: i64,
user_id: i64,
content: &str,
) -> Result<Comment, LificError> {
let content = unescape_text(content);
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM issues WHERE id = ?1",
params![issue_id],
|row| row.get(0),
)
.unwrap_or(false);
if !exists {
return Err(LificError::NotFound(format!("issue {issue_id} not found")));
}
conn.execute(
"INSERT INTO comments (issue_id, user_id, content) VALUES (?1, ?2, ?3)",
params![issue_id, user_id, content],
)?;
let id = conn.last_insert_rowid();
get_comment(conn, id)
}
pub fn get_comment(conn: &Connection, id: i64) -> Result<Comment, LificError> {
conn.query_row(
"SELECT c.id, c.issue_id, c.user_id, u.username, u.display_name,
c.content, c.created_at, c.updated_at
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.id = ?1",
params![id],
row_to_comment,
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => {
LificError::NotFound(format!("comment {id} not found"))
}
other => other.into(),
})
}
pub fn list_comments(conn: &Connection, issue_id: i64) -> Result<Vec<Comment>, LificError> {
let mut stmt = conn.prepare(
"SELECT c.id, c.issue_id, c.user_id, u.username, u.display_name,
c.content, c.created_at, c.updated_at
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.issue_id = ?1
ORDER BY c.created_at ASC",
)?;
let rows = stmt.query_map(params![issue_id], row_to_comment)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn update_comment(conn: &Connection, id: i64, content: &str) -> Result<Comment, LificError> {
let content = unescape_text(content);
let changed = conn.execute(
"UPDATE comments SET content = ?1, updated_at = datetime('now') WHERE id = ?2",
params![content, id],
)?;
if changed == 0 {
return Err(LificError::NotFound(format!("comment {id} not found")));
}
get_comment(conn, id)
}
pub fn delete_comment(conn: &Connection, id: i64) -> Result<(), LificError> {
let changed = conn.execute("DELETE FROM comments WHERE id = ?1", params![id])?;
if changed == 0 {
return Err(LificError::NotFound(format!("comment {id} not found")));
}
Ok(())
}
fn row_to_comment(row: &rusqlite::Row) -> Result<Comment, rusqlite::Error> {
Ok(Comment {
id: row.get(0)?,
issue_id: row.get(1)?,
user_id: row.get(2)?,
author: row.get(3)?,
author_display_name: row.get(4)?,
content: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::db::models::*;
use crate::db::queries;
fn setup() -> (db::DbPool, i64, i64) {
let pool = db::open_memory().expect("test db");
let conn = pool.write().unwrap();
let user = queries::users::create_user(
&conn,
&CreateUser {
username: "blake".into(),
email: "blake@test.com".into(),
password: "testpassword1".into(),
display_name: Some("Blake".into()),
is_admin: true,
is_bot: false,
},
)
.unwrap();
let project = queries::create_project(
&conn,
&CreateProject {
name: "Test".into(),
identifier: "TST".into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap();
let issue = queries::create_issue(
&conn,
&CreateIssue {
project_id: project.id,
title: "Test issue".into(),
description: String::new(),
status: "todo".into(),
priority: "medium".into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
},
)
.unwrap();
drop(conn);
(pool, issue.id, user.id)
}
#[test]
fn create_and_list_comments() {
let (pool, issue_id, user_id) = setup();
let conn = pool.write().unwrap();
let c1 = create_comment(&conn, issue_id, user_id, "First comment").unwrap();
assert_eq!(c1.content, "First comment");
assert_eq!(c1.author, "blake");
assert_eq!(c1.author_display_name, "Blake");
assert_eq!(c1.issue_id, issue_id);
assert_eq!(c1.user_id, user_id);
let c2 = create_comment(&conn, issue_id, user_id, "Second comment").unwrap();
assert_eq!(c2.content, "Second comment");
let comments = list_comments(&conn, issue_id).unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].content, "First comment");
assert_eq!(comments[1].content, "Second comment");
}
#[test]
fn get_comment_by_id() {
let (pool, issue_id, user_id) = setup();
let conn = pool.write().unwrap();
let created = create_comment(&conn, issue_id, user_id, "Hello").unwrap();
let fetched = get_comment(&conn, created.id).unwrap();
assert_eq!(fetched.content, "Hello");
assert_eq!(fetched.author, "blake");
}
#[test]
fn update_comment_content() {
let (pool, issue_id, user_id) = setup();
let conn = pool.write().unwrap();
let created = create_comment(&conn, issue_id, user_id, "Original").unwrap();
let updated = update_comment(&conn, created.id, "Edited").unwrap();
assert_eq!(updated.content, "Edited");
assert_eq!(updated.id, created.id);
}
#[test]
fn delete_comment_removes_it() {
let (pool, issue_id, user_id) = setup();
let conn = pool.write().unwrap();
let created = create_comment(&conn, issue_id, user_id, "Delete me").unwrap();
delete_comment(&conn, created.id).unwrap();
let result = get_comment(&conn, created.id);
assert!(result.is_err());
}
#[test]
fn comment_on_nonexistent_issue_fails() {
let (pool, _, user_id) = setup();
let conn = pool.write().unwrap();
let result = create_comment(&conn, 99999, user_id, "Orphan");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn delete_nonexistent_comment_fails() {
let (pool, _, _) = setup();
let conn = pool.write().unwrap();
let result = delete_comment(&conn, 99999);
assert!(result.is_err());
}
#[test]
fn comments_cascade_on_issue_delete() {
let (pool, issue_id, user_id) = setup();
let conn = pool.write().unwrap();
let c = create_comment(&conn, issue_id, user_id, "Will be cascaded").unwrap();
queries::delete_issue(&conn, issue_id).unwrap();
let result = get_comment(&conn, c.id);
assert!(result.is_err());
}
#[test]
fn comment_unescapes_newlines() {
let (pool, issue_id, user_id) = setup();
let conn = pool.write().unwrap();
let c = create_comment(&conn, issue_id, user_id, "line1\\nline2").unwrap();
assert_eq!(c.content, "line1\nline2");
}
#[test]
fn list_comments_empty_issue() {
let (pool, issue_id, _) = setup();
let conn = pool.read().unwrap();
let comments = list_comments(&conn, issue_id).unwrap();
assert!(comments.is_empty());
}
#[test]
fn multiple_users_comment() {
let (pool, issue_id, user1_id) = setup();
let conn = pool.write().unwrap();
let user2 = queries::users::create_user(
&conn,
&CreateUser {
username: "ada".into(),
email: "ada@test.com".into(),
password: "testpassword2".into(),
display_name: Some("Ada".into()),
is_admin: false,
is_bot: true,
},
)
.unwrap();
create_comment(&conn, issue_id, user1_id, "Blake says hi").unwrap();
create_comment(&conn, issue_id, user2.id, "Ada responds").unwrap();
let comments = list_comments(&conn, issue_id).unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].author, "blake");
assert_eq!(comments[1].author, "ada");
assert_eq!(comments[1].author_display_name, "Ada");
}
}