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:
@@ -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);
|
||||
|
||||
@@ -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(¤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::<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(¤t)
|
||||
);
|
||||
assert!(execute_ticket_backend_operation(&backend, request).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user