feat: unify Ticket posts and simplify detail layout

Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
This commit is contained in:
2026-10-14 02:42:58 +09:00
parent 26ab6130e0
commit 8611078058
45 changed files with 5862 additions and 668 deletions
+54 -12
View File
@@ -51,7 +51,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
.unwrap();
}
// Snapshot evidence before migration, including untyped `done` and existing MR references.
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let old_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
@@ -79,7 +79,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
})
.unwrap();
assert_eq!(marker, 10);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
assert_eq!(after_events, historical_events);
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
for state in [
@@ -129,11 +129,11 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
assert_eq!(ticket.meta.targets[0].repository_key, "main");
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
assert_eq!(ticket.events[1].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[1].to.as_deref(), Some(state));
assert_eq!(ticket.events[1].references[0].target, "MR-existing");
assert_eq!(
ticket.events[0].attributes["request_fingerprint"],
ticket.events[1].attributes["request_fingerprint"],
"legacy-fingerprint"
);
if expected_state == TicketState::Closed {
@@ -254,8 +254,26 @@ fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() {
// Read actual SQLite values, not merely counts: receipt JSON, original context,
// name snapshots and event/reference/attribute rows must survive byte-for-byte.
fn rows(connection: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
// Compare the retained v7-v11 payload, excluding the new initial post and metadata.
let modern: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('typed_tickets') WHERE name='created_by')", [], |r| r.get(0)).unwrap();
let projection = match table {
"typed_tickets" if modern => {
"workspace_id,ticket_id,slug,title,status,kind,priority,(SELECT body FROM typed_ticket_events e WHERE e.workspace_id=typed_tickets.workspace_id AND e.ticket_id=typed_tickets.ticket_id AND e.event_index=-1),created_at,updated_at,assignee,readiness,workflow_state,workflow_state_explicit,queued_by,queued_at,resolution"
}
"typed_ticket_events" => {
"workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body"
}
_ => "*",
};
let filter = if table.starts_with("typed_ticket_event") {
"WHERE event_index >= 0"
} else {
""
};
let mut stmt = connection
.prepare(&format!("SELECT * FROM {table} ORDER BY rowid"))
.prepare(&format!(
"SELECT {projection} FROM {table} {filter} ORDER BY rowid"
))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
@@ -272,11 +290,24 @@ fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str
)
.unwrap();
for table in tables {
connection
.execute_batch(&format!(
"INSERT INTO {table} SELECT * FROM fixture_source.{table}"
))
let columns = connection
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
let selection = columns.iter().map(|column| {
if *table == "typed_tickets" && column == "body" {
"(SELECT body FROM fixture_source.typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id AND e.event_index=-1)".to_owned()
} else { format!("t.{column}") }
}).collect::<Vec<_>>().join(",");
let filter = if table.starts_with("typed_ticket_event") {
"WHERE t.event_index >= 0"
} else {
""
};
connection.execute_batch(&format!("INSERT INTO {table} ({}) SELECT {selection} FROM fixture_source.{table} t {filter}", columns.join(","))).unwrap();
}
connection
.execute_batch("DETACH DATABASE fixture_source")
@@ -559,12 +590,23 @@ fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_recei
),
)
.unwrap();
let reopened = source
let mut reopened = source
.update_case(
&id,
case_request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
reopened.events[0]
.attributes
.retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence"));
// The frozen v9 copy has no persisted event_id attributes. Its published
// fallback was a zero-based ordinal, not the source v12 persisted identity.
for (ordinal, event) in reopened.events.iter_mut().skip(1).enumerate() {
event.event_ref = format!("sequence:{ordinal}");
event
.attributes
.insert("event_id".into(), event.event_ref.clone());
}
let path = temp.path().join("retained9.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
+525
View File
@@ -0,0 +1,525 @@
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(&current)
);
} else {
assert_eq!(
ticket_content_digest(&previous),
ticket_content_digest(&current)
);
}
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::<rusqlite::Result<Vec<_>>>().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(),
12
);
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::<rusqlite::Result<_>>().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::<Vec<_>>(),
["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(&current)
);
assert!(execute_ticket_backend_operation(&backend, request).is_err());
}