#[allow(dead_code)]
#[path = "../src/db.rs"]
mod db;
#[allow(dead_code)]
#[path = "../src/tui/draft.rs"]
pub mod draft;
#[allow(dead_code)]
#[path = "../src/herdr.rs"]
mod herdr;
#[allow(dead_code)]
#[path = "../src/identity.rs"]
mod identity;
#[allow(dead_code)]
#[path = "../src/images.rs"]
mod images;
#[allow(dead_code)]
#[path = "../src/sql_filter/mod.rs"]
mod sql_filter;
mod tui {
pub use crate::draft;
}
use db::Db;
use draft::{Composition, Draft};
use images::{ImageInput, ImageStore};
use std::process::{Command, Stdio};
fn database() -> (Db, tempfile::TempDir) {
let dir = tempfile::TempDir::new().unwrap();
let conn = rusqlite::Connection::open(dir.path().join("qqq.db")).unwrap();
conn.execute_batch(include_str!("../src/schema.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v2.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v3.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v4.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v5.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v6.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v7.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v8.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/migrate_v9.sql"))
.unwrap();
(
Db {
conn,
image_store: ImageStore::new(dir.path().join("images")),
},
dir,
)
}
fn composition() -> Composition {
Composition {
description: "Details".into(),
images: vec![ImageInput {
name: "ok.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
}],
image_spans: Vec::new(),
}
}
#[test]
fn task_messages_preserve_body_author_order_and_show_contract() {
let (mut db, dir) = database();
let task = db.add("Selected", None, &[]).unwrap();
assert!(db.task_messages(task.id).unwrap().is_empty());
db.message(task.id, "First\nDetails", Some("worker"))
.unwrap();
db.message(task.id, "Second", None).unwrap();
let messages = db.task_messages(task.id).unwrap();
assert_eq!(messages.len(), 2);
assert!(messages[0].id < messages[1].id);
assert_eq!(messages[0].body, "First\nDetails");
assert_eq!(messages[0].session.as_deref(), Some("worker"));
assert!(messages[1].session.is_none());
assert!(messages[0].created_at.ends_with('Z'));
assert_eq!(
db.show(task.id).unwrap()["messages"],
serde_json::to_value(messages).unwrap()
);
assert_eq!(db.show_task(&task).unwrap(), db.show(task.id).unwrap());
let task = db.list(None).unwrap().pop().unwrap();
let version = db.data_version().unwrap();
let external = rusqlite::Connection::open(dir.path().join("qqq.db")).unwrap();
external
.execute("DELETE FROM messages WHERE task_id=?", [task.id])
.unwrap();
external
.execute("DELETE FROM tasks WHERE id=?", [task.id])
.unwrap();
assert!(db.show(task.id).is_err());
let snapshot = db.show_task(&task).unwrap();
assert_eq!(snapshot["task"]["id"], task.id);
assert_eq!(snapshot["task"]["description"], "Selected");
for section in ["messages", "images", "events"] {
assert_eq!(snapshot[section], serde_json::json!([]));
}
assert!(snapshot["herdr"].is_null());
assert_ne!(db.data_version().unwrap(), version);
assert!(db.list(None).unwrap().is_empty());
}
#[test]
fn task_navigation_skips_archived_unless_included() {
let (mut db, _dir) = database();
db.add("First", None, &[]).unwrap();
db.add("Hidden", None, &[]).unwrap();
db.add("Third", None, &[]).unwrap();
db.set_archived(2, true, "cli").unwrap();
assert_eq!(db.adjacent_task(None, true, false).unwrap().unwrap().0, 3);
assert_eq!(
db.adjacent_task(Some(3), true, false).unwrap().unwrap().0,
1
);
assert_eq!(
db.adjacent_task(Some(1), false, false).unwrap().unwrap().0,
3
);
assert_eq!(db.adjacent_task(Some(3), true, true).unwrap().unwrap().0, 2);
}
#[test]
fn legacy_open_keeps_foreign_keys_enabled() {
if std::env::var_os("QQQ_TEST_LEGACY_FK_CHILD").is_none() {
return;
}
let (db, _) = Db::open(false).unwrap();
let enabled: i64 = db
.conn
.pragma_query_value(None, "foreign_keys", |row| row.get(0))
.unwrap();
assert_eq!(enabled, 1);
}
#[test]
fn concurrent_legacy_opens_keep_foreign_keys_enabled() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".qqq")).unwrap();
let conn = rusqlite::Connection::open(dir.path().join(".qqq/qqq.db")).unwrap();
for migration in [
include_str!("../src/schema.sql"),
include_str!("../src/migrate_v2.sql"),
include_str!("../src/migrate_v3.sql"),
include_str!("../src/migrate_v4.sql"),
include_str!("../src/migrate_v5.sql"),
] {
conn.execute_batch(migration).unwrap();
}
conn.execute("INSERT INTO tasks(description) VALUES ('Legacy')", [])
.unwrap();
conn.execute(
"INSERT INTO images(task_id,name,media_type,data) VALUES (1,'legacy.png','image/png',?1)",
[vec![1_u8; 4 * 1024 * 1024]],
)
.unwrap();
drop(conn);
let children: Vec<_> = (0..6)
.map(|_| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "legacy_open_keeps_foreign_keys_enabled"])
.env("QQQ_TEST_LEGACY_FK_CHILD", "1")
.current_dir(dir.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap()
})
.collect();
for child in children {
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn pasted_images_get_stored_markdown_paths_after_image_ids_are_allocated() {
let (mut db, dir) = database();
let mut draft = Draft::new("See ");
draft
.image(ImageInput {
name: "a]b.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
})
.unwrap();
draft
.image(ImageInput {
name: "second.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
})
.unwrap();
let mut composition = draft.finish().unwrap();
composition.images.push(ImageInput {
name: "flag.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
});
let task = db.save_composition(None, None, &composition).unwrap();
assert_eq!(
task.description,
"See ![a\\]b.png](.qqq/images/1/1.png)"
);
assert_eq!(
db.show(task.id).unwrap()["images"]
.as_array()
.unwrap()
.len(),
3
);
let references = db.image_references(task.id).unwrap();
assert_eq!(
(references[0].id, references[0].name.as_str()),
(1, "a]b.png")
);
assert_eq!(
(references[1].id, references[1].name.as_str()),
(2, "second.png")
);
assert_eq!(
(references[2].id, references[2].name.as_str()),
(3, "flag.png")
);
assert!(dir.path().join("images/1/1.png").exists());
assert!(dir.path().join("images/1/2.png").exists());
assert!(dir.path().join("images/1/3.png").exists());
let mut edit = Draft::new(&task.description);
edit.image(ImageInput {
name: "third.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
})
.unwrap();
let updated = db
.save_composition(Some(task.id), None, &edit.finish().unwrap())
.unwrap();
assert_eq!(
updated.description,
"See ![a\\]b.png](.qqq/images/1/1.png)"
);
}
#[test]
fn failed_pasted_image_save_rolls_back_link_and_file() {
let (mut db, dir) = database();
let task = db.add("Original", None, &[]).unwrap();
db.conn.execute_batch("CREATE TRIGGER reject_image BEFORE INSERT ON images WHEN NEW.name='fail.png' BEGIN SELECT RAISE(ABORT,'image rejected'); END;").unwrap();
let mut draft = Draft::new("Changed ");
for name in ["ok.png", "fail.png"] {
draft
.image(ImageInput {
name: name.into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
})
.unwrap();
}
assert!(
db.save_composition(Some(task.id), None, &draft.finish().unwrap())
.is_err()
);
assert_eq!(db.task(task.id).unwrap().description, "Original");
assert!(db.image_references(task.id).unwrap().is_empty());
assert!(!dir.path().join("images/1/1.png").exists());
}
#[test]
fn composition_saves_task_and_image_bytes_together() {
let (mut db, dir) = database();
let task = db.save_composition(None, None, &composition()).unwrap();
assert_eq!(task.description, "Details");
assert_eq!(db.show(task.id).unwrap()["images"][0]["name"], "ok.png");
let bytes = std::fs::read(dir.path().join("images/1/1.png")).unwrap();
assert_eq!(bytes, b"\x89PNG\r\n\x1a\n");
}
#[test]
fn composition_saves_pasteboard_block_and_image_link_together() {
let (mut db, _dir) = database();
let payload = format!("```\n{}", "x".repeat(1001));
let mut draft = Draft::new("Before ");
draft.paste(&payload);
draft
.image(ImageInput {
name: "after.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
})
.unwrap();
let task = db
.save_composition(None, None, &draft.finish().unwrap())
.unwrap();
assert_eq!(
task.description,
format!("Before \n````pasteboard\n{payload}\n````\n")
);
let loaded = Draft::from_saved(
&task.description,
task.id,
&db.image_references(task.id).unwrap(),
)
.unwrap();
assert!(
loaded
.fragments()
.iter()
.any(|part| part.starts_with("[Pasted Content "))
);
assert!(
loaded
.fragments()
.iter()
.any(|part| part == "[Image #1: after.png]")
);
assert_eq!(loaded.finish().unwrap().description, task.description);
}
#[test]
fn failed_image_insert_rolls_back_new_and_edited_drafts() {
let (mut db, _dir) = database();
db.conn.execute_batch("CREATE TRIGGER reject_image BEFORE INSERT ON images WHEN NEW.name='fail.png' BEGIN SELECT RAISE(ABORT,'image rejected'); END;").unwrap();
let mut draft = composition();
draft.images.push(ImageInput {
name: "fail.png".into(),
data: b"\x89PNG\r\n\x1a\n".to_vec(),
});
assert!(db.save_composition(None, None, &draft).is_err());
assert!(db.list(None).unwrap().is_empty());
let existing = db.add("Original\n\nOriginal details", None, &[]).unwrap();
let before = db.show(existing.id).unwrap();
assert!(
db.save_composition(Some(existing.id), None, &draft)
.is_err()
);
assert_eq!(db.show(existing.id).unwrap(), before);
}
#[test]
fn draft_edit_preserves_ownership_dependencies_and_existing_attachments() {
let (mut db, _dir) = database();
let parent = db.add("Parent", None, &[]).unwrap();
db.next("parent", None).unwrap();
db.complete(parent.id, "parent", None).unwrap();
let task = db
.save_composition(None, Some(parent.id), &composition())
.unwrap();
db.next("worker", None).unwrap();
db.message(task.id, "Note", Some("worker")).unwrap();
let updated = db
.save_composition(Some(task.id), None, &composition())
.unwrap();
assert_eq!(updated.status, "in_progress");
assert_eq!(updated.identity.harness_session.as_deref(), Some("worker"));
assert_eq!(updated.parent_id, Some(parent.id));
let shown = db.show(task.id).unwrap();
assert_eq!(shown["images"].as_array().unwrap().len(), 2);
assert_eq!(shown["messages"][0]["body"], "Note");
}