//! Real SQLite component tests through TicketBackend, including durable edit facts. //! Host capture is represented only by test triggers; Workspace routing is not tested here. #[path = "posts/content_changes.rs"] mod content_changes; use rusqlite::{Connection, params}; use tempfile::TempDir; use ticket::{ MarkdownText, NewTicket, NewTicketEvent, SqliteTicketBackend, TicketBackend, TicketEventKind, TicketItemEdit, TicketPostEdit, migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction, ticket_content_digest, verify_sqlite_ticket_schema, }; fn comment(body: &str, author: &str) -> NewTicketEvent { let mut event = NewTicketEvent::new(TicketEventKind::Comment, body); event.author = Some(author.into()); event } #[test] fn initial_and_discussion_comments_share_identity_authorship_and_cas_edits() { let temp = TempDir::new().unwrap(); let path = temp.path().join("posts.db"); let backend = SqliteTicketBackend::open(&path, "workspace").unwrap(); let mut input = NewTicket::new("Real first post"); input.body = MarkdownText::new("First authored post"); input.author = Some("creator".into()); let (reference, created) = backend.create_with_snapshot(input).unwrap(); assert_eq!(created.events.len(), 1); let initial = &created.events[0]; assert_eq!(initial.kind, TicketEventKind::Comment); assert!(initial.is_initial); assert_eq!(initial.body, created.document.body); assert_eq!(initial.author.as_deref(), Some("creator")); assert_eq!(initial.at, created.meta.created_at); assert_eq!(created.meta.created_by.as_deref(), Some("creator")); assert!(initial.edited_at.is_none()); backend .add_event( reference.id.clone().into(), comment("Discussion", "commenter"), ) .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); let ordinary = before.events[1].clone(); assert!(!ordinary.is_initial); assert_eq!(ordinary.kind, initial.kind); let initial_time = initial.at.clone(); for original in [ordinary, initial.clone()] { let edit = TicketPostEdit { event_ref: original.event_ref.clone(), expected_body: original.body.clone(), body: MarkdownText::new(format!("Edited {}", original.event_ref)), author: "editor".into(), }; let (previous, current) = backend .edit_post_with_snapshots(reference.id.clone().into(), edit.clone()) .unwrap(); let post = current .events .iter() .find(|p| p.event_ref == original.event_ref) .unwrap(); assert_eq!(post.author, original.author); assert_eq!(post.at, original.at); assert_eq!(post.edited_by.as_deref(), Some("editor")); assert!(post.edited_at.is_some()); assert_eq!(current.meta.created_by.as_deref(), Some("creator")); assert_eq!(current.meta.created_at, initial_time); assert_eq!(current.events.len(), 2); if original.is_initial { assert_eq!(current.document.body, edit.body); assert_ne!( ticket_content_digest(&previous), ticket_content_digest(¤t) ); } else { assert_eq!( ticket_content_digest(&previous), ticket_content_digest(¤t) ); } assert!( backend .edit_post(reference.id.clone().into(), edit) .is_err() ); assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current); } let previous = backend.show(reference.id.clone().into()).unwrap(); let edited = backend .edit_item( reference.id.clone().into(), TicketItemEdit { title: Some("New title".into()), body: Some(MarkdownText::new("Item authoring edits initial post")), author: Some("item-editor".into()), ..Default::default() }, ) .unwrap(); assert_eq!(edited.events.len(), previous.events.len()); assert_eq!(edited.events[0].author, previous.events[0].author); assert_eq!(edited.events[0].at, previous.events[0].at); assert_eq!(edited.events[0].edited_by.as_deref(), Some("item-editor")); assert_eq!(edited.document.body, edited.events[0].body); assert!( edited .events .iter() .all(|e| !matches!(e.kind.as_str(), "create" | "item_edit")) ); let reopened = SqliteTicketBackend::open(&path, "workspace").unwrap(); assert_eq!(reopened.show(reference.id.into()).unwrap(), edited); let conn = Connection::open(path).unwrap(); assert_eq!( conn.query_row( "SELECT count(*) FROM pragma_table_info('typed_tickets') WHERE name='body'", [], |r| r.get::<_, i64>(0) ) .unwrap(), 0 ); } #[test] fn editing_rejects_system_judgments_other_tickets_and_missing_identity() { let temp = TempDir::new().unwrap(); let backend = SqliteTicketBackend::open(temp.path().join("posts.db"), "workspace").unwrap(); let first = backend.create(NewTicket::new("first")).unwrap(); let second = backend.create(NewTicket::new("second")).unwrap(); let mut event = comment("System judgment", "system"); event.kind = TicketEventKind::Decision; backend.add_event(first.id.clone().into(), event).unwrap(); let ticket = backend.show(first.id.clone().into()).unwrap(); let system = &ticket.events[1]; let edit = TicketPostEdit { event_ref: system.event_ref.clone(), expected_body: system.body.clone(), body: MarkdownText::new("changed"), author: "editor".into(), }; assert!( backend .edit_post(first.id.clone().into(), edit.clone()) .is_err() ); assert!(backend.edit_post(second.id.into(), edit).is_err()); let initial = &ticket.events[0]; assert!( backend .edit_post( first.id.clone().into(), TicketPostEdit { event_ref: initial.event_ref.clone(), expected_body: initial.body.clone(), body: MarkdownText::new("changed"), author: " ".into() } ) .is_err() ); assert_eq!(backend.show(first.id.into()).unwrap(), ticket); } fn schema11(conn: &Connection) { // Build the actual retained component shape, not a current schema with its marker changed. conn.execute_batch(include_str!("../src/latest_schema.sql")) .unwrap(); conn.execute_batch(include_str!("../src/assignment_schema.sql")) .unwrap(); conn.execute_batch(include_str!("../src/assignment_tracking_schema.sql")) .unwrap(); conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(11,'ticket schema baseline','original-marker');").unwrap(); } fn seed_legacy(conn: &Connection, id: &str) { conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,workflow_state,workflow_state_explicit) VALUES('workspace',?1,?1,'Legacy','open','task','P2','Latest body','ticket-time','open',1)", [id]).unwrap(); } #[test] fn schema12_migrates_body_authors_archived_history_and_stable_references_then_replays() { let temp = TempDir::new().unwrap(); let path = temp.path().join("legacy.db"); let conn = Connection::open(&path).unwrap(); conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); schema11(&conn); for id in ["known", "unknown", "metadata"] { seed_legacy(&conn, id); } conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES ('workspace','known',0,'create','original-author','original-time','Created'), ('workspace','known',1,'comment','discussant','discussion-time','Discussion'), ('workspace','known',2,'item_edit','legacy-editor','edit-time','Body changed'); INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',1,'event_id','external-original-id'); INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',2,'body_edit','whole'); INSERT INTO typed_ticket_event_references VALUES('workspace','known',2,0,'commit','original-evidence'); INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','metadata','created_by','metadata-author');").unwrap(); migrate_sqlite_ticket_schema(&conn).unwrap(); verify_sqlite_ticket_schema(&conn).unwrap(); let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); let known = backend.show("known".into()).unwrap(); assert_eq!(known.meta.created_at.as_deref(), Some("ticket-time")); assert_eq!(known.meta.created_by.as_deref(), Some("original-author")); assert_eq!(known.events.len(), 2); assert_eq!(known.events[0].author.as_deref(), Some("original-author")); assert_eq!(known.events[0].at.as_deref(), Some("original-time")); assert_eq!(known.document.body.as_str(), "Latest body"); assert_eq!(known.events[0].body, known.document.body); assert_eq!(known.events[1].event_ref, "known:1"); assert_eq!(known.events[1].attributes["event_id"], "known:1"); assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='known' AND event_index=1 AND key='event_id'", [], |r| r.get::<_,String>(0)).unwrap(), "external-original-id"); assert_eq!(known.events[1].attributes["event_sequence"], "1"); let unknown = backend.show("unknown".into()).unwrap(); assert_eq!(unknown.meta.created_by, None); assert_eq!(unknown.events[0].author, None); assert_eq!(unknown.events[0].at.as_deref(), Some("ticket-time")); assert_eq!( backend.show("metadata".into()).unwrap().events[0] .author .as_deref(), Some("metadata-author") ); let history = || { conn.prepare("SELECT event_index,kind,author,at,body,archived FROM typed_ticket_events WHERE ticket_id='known' AND event_index>=0 ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,bool>(5)?))).unwrap().collect::>>().unwrap() }; let original_history = history(); assert_eq!( original_history[0], ( 0, "create".into(), "original-author".into(), "original-time".into(), "Created".into(), true ) ); assert_eq!( original_history[2], ( 2, "item_edit".into(), "legacy-editor".into(), "edit-time".into(), "Body changed".into(), true ) ); assert_eq!(conn.query_row("SELECT target FROM typed_ticket_event_references WHERE ticket_id='known' AND event_index=2", [], |r| r.get::<_,String>(0)).unwrap(), "original-evidence"); migrate_sqlite_ticket_schema(&conn).unwrap(); assert_eq!(history(), original_history); assert_eq!(backend.show("known".into()).unwrap(), known); assert_eq!( conn.query_row( "SELECT applied_at FROM ticket_schema_migrations WHERE version=11", [], |r| r.get::<_, String>(0) ) .unwrap(), "original-marker" ); assert_eq!( conn.query_row( "SELECT max(version) FROM ticket_schema_migrations", [], |r| r.get::<_, i64>(0) ) .unwrap(), 15 ); backend .add_event("known".into(), comment("Next", "author")) .unwrap(); assert_eq!( backend .show("known".into()) .unwrap() .events .last() .unwrap() .event_ref, "known:3" ); } #[test] fn schema12_reserved_index_collision_rolls_back_component_and_host_can_retry() { let conn = Connection::open_in_memory().unwrap(); schema11(&conn); seed_legacy(&conn, "collision"); conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('workspace','collision',-1,'comment','retained negative index')", []).unwrap(); conn.execute_batch("CREATE TABLE host_work(value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES('pending');").unwrap(); assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err()); assert!(!conn.is_autocommit()); assert_eq!( conn.query_row( "SELECT body FROM typed_tickets WHERE ticket_id='collision'", [], |r| r.get::<_, String>(0) ) .unwrap(), "Latest body" ); assert_eq!( conn.query_row( "SELECT count(*) FROM pragma_table_info('typed_ticket_events') WHERE name='event_ref'", [], |r| r.get::<_, i64>(0) ) .unwrap(), 0 ); assert_eq!( conn.query_row( "SELECT max(version) FROM ticket_schema_migrations", [], |r| r.get::<_, i64>(0) ) .unwrap(), 11 ); conn.execute_batch("ROLLBACK").unwrap(); assert_eq!( conn.query_row("SELECT count(*) FROM host_work", [], |r| r.get::<_, i64>(0)) .unwrap(), 0 ); // Remove the deliberately conflicting test row, then retry the unchanged migration. conn.execute( "DELETE FROM typed_ticket_events WHERE event_index=?1", params![-1], ) .unwrap(); migrate_sqlite_ticket_schema(&conn).unwrap(); verify_sqlite_ticket_schema(&conn).unwrap(); } #[test] fn migration_preserves_published_refs_before_archiving_even_with_sparse_indices() { let temp = TempDir::new().unwrap(); let path = temp.path().join("identities.db"); let conn = Connection::open(&path).unwrap(); schema11(&conn); seed_legacy(&conn, "sparse"); conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES ('workspace','sparse',3,'create','Created'), ('workspace','sparse',12,'item_edit','Edited'), ('workspace','sparse',90,'comment','Existing comment'), ('workspace','sparse',99,'comment','Explicit identity'); INSERT INTO typed_ticket_event_attributes VALUES('workspace','sparse',99,'event_id','published-custom-id');").unwrap(); migrate_sqlite_ticket_schema(&conn).unwrap(); let refs: Vec<(i64, String)> = conn.prepare("SELECT event_index,event_ref FROM typed_ticket_events WHERE ticket_id='sparse' ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get(0)?, r.get(1)?))).unwrap().collect::>().unwrap(); assert_eq!( refs, vec![ (-1, "sparse:-1".into()), (3, "sparse:3".into()), (12, "sparse:12".into()), (90, "sparse:90".into()), (99, "sparse:99".into()) ] ); let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); let ticket = backend.show("sparse".into()).unwrap(); assert_eq!(ticket.events[1].event_ref, "sparse:90"); assert_eq!(ticket.events[1].attributes["event_id"], "sparse:90"); assert_eq!(ticket.events[1].attributes["event_sequence"], "90"); let edited = backend .edit_post( "sparse".into(), TicketPostEdit { event_ref: "sparse:90".into(), expected_body: MarkdownText::new("Existing comment"), body: MarkdownText::new("Updated comment"), author: "editor".into(), }, ) .unwrap(); assert_eq!(edited.events[1].event_ref, "sparse:90"); migrate_sqlite_ticket_schema(&conn).unwrap(); assert_eq!(backend.show("sparse".into()).unwrap(), edited); } #[test] fn migration_preserves_raw_identity_attributes_without_rebinding_published_refs() { for event_ids in [["same", "same"], ["sequence:1", ""], ["collision:-1", ""]] { let temp = TempDir::new().unwrap(); let path = temp.path().join("identity-attributes.db"); let conn = Connection::open(&path).unwrap(); schema11(&conn); seed_legacy(&conn, "collision"); for (index, event_id) in event_ids.iter().enumerate() { conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('workspace','collision',?1,'comment','retained')", [index as i64]).unwrap(); if !event_id.is_empty() { conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('workspace','collision',?1,'event_id',?2)", params![index as i64,event_id]).unwrap(); } } migrate_sqlite_ticket_schema(&conn).unwrap(); let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); let migrated = backend.show("collision".into()).unwrap(); assert_eq!( migrated .events .iter() .map(|e| e.event_ref.as_str()) .collect::>(), ["collision:-1", "collision:0", "collision:1"] ); for (index, event_id) in event_ids.iter().enumerate() { let event = &migrated.events[index + 1]; assert_eq!(event.attributes["event_id"], format!("collision:{index}")); if !event_id.is_empty() { assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='collision' AND event_index=?1 AND key='event_id'", [index as i64], |r| r.get::<_, String>(0)).unwrap(), *event_id); } } migrate_sqlite_ticket_schema(&conn).unwrap(); assert_eq!(backend.show("collision".into()).unwrap(), migrated); } } #[test] fn migration_recovers_only_last_known_body_edit_and_never_invents_creation_author() { let temp = TempDir::new().unwrap(); let path = temp.path().join("edit-metadata.db"); let conn = Connection::open(&path).unwrap(); schema11(&conn); for id in [ "known", "unknown-last-editor", "title-only", "empty-creator", ] { seed_legacy(&conn, id); } conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES ('workspace','known',0,'create','creator','original-time','Created'), ('workspace','known',1,'item_edit','old-editor','old-time','old body edit'), ('workspace','known',5,'item_edit','body-editor','body-time','latest body edit'), ('workspace','known',6,'item_edit','title-editor','title-time','later title edit'), ('workspace','unknown-last-editor',0,'item_edit','old-editor','old-time','old body edit'), ('workspace','unknown-last-editor',1,'item_edit',NULL,NULL,'last body edit'), ('workspace','title-only',0,'item_edit','title-editor','title-time','not a body edit'), ('workspace','empty-creator',0,'create',' ','create-time','Created'); INSERT INTO typed_ticket_event_attributes VALUES ('workspace','known',1,'changes','body'), ('workspace','known',5,'changes','title,body,targets'), ('workspace','known',6,'changes','title'), ('workspace','unknown-last-editor',0,'changes','body'), ('workspace','unknown-last-editor',1,'changes','body'), ('workspace','title-only',0,'changes','title,body_format'); INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','empty-creator','created_by','');").unwrap(); migrate_sqlite_ticket_schema(&conn).unwrap(); let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); let known = backend.show("known".into()).unwrap(); let post = &known.events[0]; assert_eq!(post.author.as_deref(), Some("creator")); assert_eq!(post.at.as_deref(), Some("original-time")); assert_eq!(post.edited_at.as_deref(), Some("body-time")); assert_eq!(post.edited_by.as_deref(), Some("body-editor")); assert_eq!(known.document.body.as_str(), "Latest body"); for id in ["unknown-last-editor", "title-only", "empty-creator"] { let ticket = backend.show(id.into()).unwrap(); assert_eq!(ticket.events[0].edited_at, None, "{id}"); assert_eq!(ticket.events[0].edited_by, None, "{id}"); assert_eq!(ticket.events[0].author, None, "{id}"); assert_eq!(ticket.meta.created_by, None, "{id}"); let json = serde_json::to_value(ticket).unwrap(); assert!(json["meta"].as_object().unwrap().contains_key("created_by")); assert!(json["meta"]["created_by"].is_null()); assert!(json["events"][0]["author"].is_null()); } migrate_sqlite_ticket_schema(&conn).unwrap(); assert_eq!(backend.show("known".into()).unwrap(), known); // Blank new creation identities use the same honest nullable contract. let mut input = NewTicket::new("Unknown creator"); input.author = Some(" ".into()); let (_, created) = backend.create_with_snapshot(input).unwrap(); assert_eq!(created.meta.created_by, None); assert_eq!(created.events[0].author, None); } #[test] fn edit_post_dispatch_round_trips_atomic_ticket_snapshots() { use ticket::{ TicketBackendOperation, TicketBackendOperationResult, execute_ticket_backend_operation, }; let temp = TempDir::new().unwrap(); let backend = SqliteTicketBackend::open(temp.path().join("dispatch.db"), "workspace").unwrap(); let (reference, before) = backend .create_with_snapshot(NewTicket::new("Dispatch")) .unwrap(); let request = TicketBackendOperation::EditPost { id: reference.id.clone().into(), edit: TicketPostEdit { event_ref: before.events[0].event_ref.clone(), expected_body: before.document.body.clone(), body: MarkdownText::new("Rescoped requirements"), author: "caller".into(), }, }; let json = serde_json::to_value(&request).unwrap(); assert_eq!(json["operation"], "edit_post"); let decoded = serde_json::from_value(json).unwrap(); let result = execute_ticket_backend_operation(&backend, decoded).unwrap(); let wire = serde_json::to_value(&result).unwrap(); assert_eq!(wire["result"], "ticket_snapshots"); assert_eq!( wire["value"]["previous"]["document"]["body"], before.document.body.as_str() ); assert_eq!( wire["value"]["current"]["document"]["body"], "Rescoped requirements" ); let TicketBackendOperationResult::TicketSnapshots { previous, current } = serde_json::from_value(wire).unwrap() else { panic!("wrong result variant") }; assert_eq!(previous, before); assert_eq!(backend.show(reference.id.into()).unwrap(), current); assert_ne!( ticket_content_digest(&previous), ticket_content_digest(¤t) ); assert!(execute_ticket_backend_operation(&backend, request).is_err()); }