// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! oakstorage database-backend integration tests (plan M13 D1, the //! aggregation-granularity design). //! //! End-to-end against real SQLite library files in temp directories: //! full-feature round-trip through a fresh session, the node-granularity //! journal (import + diff), snapshot + journal replay, undo to any //! point, snapshot pruning and journal retention, the project-manager //! API surface (list/delete/duplicate/rename/import/export) and the //! error/URI matrices. Nothing touches a real library. The same //! behaviors run against real PostgreSQL in `database_pg_test.rs` //! (gated on `OAK_TEST_PG_URL`); both suites share the fixtures and //! helpers in `tests/common/mod.rs`. mod common; use std::path::PathBuf; use sea_orm::entity::prelude::*; use sea_orm::QueryOrder; use oaknode::id::NodeId; use oaknode::value::NodeValue; use oakstorage::backend::StorageBackend; use oakstorage::backends::database::entities::{journal, project, snapshot}; use oakstorage::backends::database::{ derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP, }; use oakstorage::error::{ OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_IO, OAKSTORAGE_E_NO_BACKEND, OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK, }; use oakstorage::nodeutil::project_arc; use oakstorage::registry::Registry; use oakstorage::uri::StorageUri; use common::*; // --------------------------------------------------------------------------- // Round-trip through a fresh session // --------------------------------------------------------------------------- #[test] fn roundtrip_field_by_field() { let dir = temp_dir("rt"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); // A new backend instance = a new session; the library row is // selected explicitly by uuid. let session = DatabaseBackend::new(); let (loaded_uuid, loaded) = load_project(&session, &project_uri(&uri, &uuid)); assert_eq!(loaded_uuid, uuid); { let o = project.lock().unwrap(); let l = loaded.lock().unwrap(); assert_full_fields(&o, &l); } // The default (no ?project=) pick returns the same single row. let session = DatabaseBackend::new(); let (loaded_uuid, _) = load_project(&session, &uri); assert_eq!(loaded_uuid, uuid); // The settings mirror carries the current keys. let row = inspect_sqlite(&db, |conn| async move { project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) .await .unwrap() .unwrap() .id }); let mirrored = settings_rows(&db, row); assert!(mirrored.contains(&("projectname".to_string(), "full-fixture".to_string())), "{mirrored:?}"); } #[test] fn registry_routes_oakdb_schemes() { assert_eq!( Registry::global() .resolve(&StorageUri::parse("oakdb+sqlite:///tmp/x.db").unwrap()) .unwrap() .name(), "oakdb" ); assert_eq!( Registry::global() .resolve(&StorageUri::parse("oakdb+pg://user@host/db").unwrap()) .unwrap() .name(), "oakdb" ); // The legacy `oakdb://` (no sub-scheme) stays unclaimed. assert_eq!( Registry::global() .resolve(&StorageUri::parse("oakdb://user@host/db").unwrap()) .err() .unwrap() .code(), OAKSTORAGE_E_NO_BACKEND ); } // --------------------------------------------------------------------------- // Journal semantics: import, diff, no-op save // --------------------------------------------------------------------------- /// First save is one `kind='import'` command carrying every node plus /// the settings pseudo-node (plan §2); the project row advances to seq 1. #[test] fn first_save_is_an_import_command() { let dir = temp_dir("imp"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); let node_count = project.lock().unwrap().graph.node_count(); save_project(&backend, &project, &uri).unwrap(); let rows = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) .await .unwrap() .unwrap(); let rows = journal::Entity::find() .filter(journal::Column::ProjectId.eq(proj.id)) .order_by_asc(journal::Column::Seq) .order_by_asc(journal::Column::NodeIdentity) .all(&conn) .await .unwrap(); (proj.id, proj.command_seq, rows) }); let (pid, command_seq, rows) = rows; assert_eq!(command_seq, 1); assert_eq!(rows.len(), node_count + 1, "every node + the settings pseudo-node"); assert!(rows.iter().all(|r| r.seq == 1 && r.kind == KIND_IMPORT), "one import command"); assert!(rows.iter().all(|r| r.old_xml.is_none()), "import has no before images"); assert!(rows.iter().all(|r| r.new_xml.is_some()), "import has after images"); // The settings pseudo-node (identity 0) is present. assert!(rows.iter().any(|r| r.node_identity == 0), "settings row present"); assert_eq!( rows.iter().filter(|r| r.node_identity == 0).count(), 1, "exactly one settings row" ); // The real-node rows use the +1 offset (settings 0 reserved). assert!(rows.iter().all(|r| r.node_identity >= 1 || r.node_identity == 0)); // The node fragments round-trip: the settings row holds the settings // element. let settings_row = rows.iter().find(|r| r.node_identity == 0).unwrap(); assert!(settings_row.new_xml.as_deref().unwrap().starts_with("")); let _ = pid; } /// A later save is a diff: only the changed nodes land as `kind='redo'` /// rows with both before and after images (plan §0). #[test] fn later_saves_are_diffs() { let dir = temp_dir("diff"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); // Change one math node's value and one setting key. { let mut p = project.lock().unwrap(); let math_ids: Vec = p .graph .node_ids() .into_iter() .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); let a = math_ids[0]; p.graph .get_mut(a) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(9.5)); p.settings.insert("projectname".to_string(), "diffed".to_string()); } save_project(&backend, &project, &uri).unwrap(); let uuid_q = uuid.clone(); let rows = inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) .await .unwrap() .unwrap(); let rows = journal::Entity::find() .filter(journal::Column::ProjectId.eq(proj.id)) .all(&conn) .await .unwrap(); (proj.id, proj.command_seq, rows) }); let (pid, command_seq, rows) = rows; assert_eq!(command_seq, 2); let seq2: Vec<&journal::Model> = rows.iter().filter(|r| r.seq == 2).collect(); assert_eq!(seq2.len(), 2, "one changed node + the settings row, nothing else"); assert!(seq2.iter().all(|r| r.kind == KIND_REDO)); assert!(seq2.iter().all(|r| r.old_xml.is_some() && r.new_xml.is_some()), "diff carries both images"); let settings_row = seq2.iter().find(|r| r.node_identity == 0).unwrap(); assert!(settings_row.new_xml.as_deref().unwrap().contains(">diffed<")); let _ = pid; // Head state after the diff reflects both changes. let session = DatabaseBackend::new(); let (_, loaded) = load_project(&session, &project_uri(&uri, &uuid)); { let l = loaded.lock().unwrap(); let math_ids: Vec = l .graph .node_ids() .into_iter() .filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); assert_eq!( l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), NodeValue::Float(9.5) ); assert_eq!(l.settings.get("projectname").cloned(), Some("diffed".to_string())); } } /// A save that changes nothing bumps no command seq and writes no rows. #[test] fn no_op_save_is_a_touch_only() { let dir = temp_dir("noop"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); save_project(&backend, &project, &uri).unwrap(); let (head, count) = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) .await .unwrap() .unwrap(); let n = journal::Entity::find() .filter(journal::Column::ProjectId.eq(proj.id)) .count(&conn) .await .unwrap(); (proj.command_seq, n) }); assert_eq!(head, 1, "no-op save keeps the head seq"); // The import wrote 12 nodes + 1 settings row; the no-op added none. assert_eq!(count, 13, "only the import rows remain"); } // --------------------------------------------------------------------------- // Snapshot + journal replay and undo to any point // --------------------------------------------------------------------------- /// With `Storage/SnapshotIntervalSec` ≤ 0 every dirty save writes a /// snapshot; the newest snapshot is the replay base and the journal rows /// after it are applied on top. Deleting every snapshot still recovers /// the same state from an empty base plus the full journal (plan §0: /// "快照损坏也能从空工程 + 全 journal 重建"). #[test] fn snapshot_and_journal_replay() { let dir = temp_dir("replay"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); with_config("Storage", "SnapshotIntervalSec", 0, || { let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); // Second command: one value change. { let mut p = project.lock().unwrap(); let math_ids: Vec = p .graph .node_ids() .into_iter() .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); p.graph .get_mut(math_ids[0]) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(7.25)); } save_project(&backend, &project, &uri).unwrap(); // Both saves produced snapshots (head seq 2). let uuid_q = uuid.clone(); let snaps = inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) .await .unwrap() .unwrap(); snapshot::Entity::find() .filter(snapshot::Column::ProjectId.eq(proj.id)) .all(&conn) .await .unwrap() }); assert_eq!(snaps.len(), 2, "one snapshot per dirty save"); // Load (head): snapshot seq 2 is the base, nothing after it. let session = DatabaseBackend::new(); let (_, loaded) = load_project(&session, &project_uri(&uri, &uuid)); { let o = project.lock().unwrap(); let l = loaded.lock().unwrap(); assert_full_fields(&o, &l); } // Destroy the snapshots: replay degrades to empty base + full // journal and still reconstructs the head. let uuid_q = uuid.clone(); inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) .await .unwrap() .unwrap(); snapshot::Entity::delete_many() .filter(snapshot::Column::ProjectId.eq(proj.id)) .exec(&conn) .await .unwrap(); }); let session = DatabaseBackend::new(); let (_, loaded) = load_project(&session, &project_uri(&uri, &uuid)); { let o = project.lock().unwrap(); let l = loaded.lock().unwrap(); assert_full_fields(&o, &l); } }); } /// The journal is the persistent undo history: `load_at(seq)` replays to /// any point (plan §0 "撤销到任意点"). #[test] fn undo_to_any_point() { let dir = temp_dir("undo"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); let math_a = { let p = project.lock().unwrap(); p.graph .node_ids() .into_iter() .find(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) .unwrap() }; // Command 1: value 2.5 (fixture default). save_project(&backend, &project, &uri).unwrap(); // Command 2: value 6.0. { let mut p = project.lock().unwrap(); p.graph .get_mut(math_a) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(6.0)); } save_project(&backend, &project, &uri).unwrap(); // Command 3: add a node. let (new_id, value) = { let mut p = project.lock().unwrap(); let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); let id = p.graph.add_node(core, behavior); p.graph .get_mut(id) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(11.0)); let v = p .graph .get(id) .unwrap() .core .standard_value("param_a_in", -1); (id, v) }; save_project(&backend, &project, &uri).unwrap(); // Head (seq 3): the extra node exists. let session = DatabaseBackend::new(); let (_, head) = load_project(&session, &project_uri(&uri, &uuid)); { let h = head.lock().unwrap(); assert!(h.graph.is_valid(new_id), "node added in command 3 is live"); assert_eq!( h.graph.get(new_id).unwrap().core.standard_value("param_a_in", -1), value ); } // Undo to seq 2: the node is gone, the value is 6.0. let session = DatabaseBackend::new(); let at2 = load_at(&session, &uri, &uuid, 2); { let l = at2.lock().unwrap(); assert!(!l.graph.is_valid(new_id), "command 3 rolled back"); let id = l .graph .node_ids() .into_iter() .find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) .unwrap(); assert_eq!( l.graph.get(id).unwrap().core.standard_value("param_a_in", -1), NodeValue::Float(6.0) ); } // Undo to seq 1: the value is back to the fixture default. let session = DatabaseBackend::new(); let at1 = load_at(&session, &uri, &uuid, 1); { let l = at1.lock().unwrap(); assert_eq!(l.graph.node_count(), 12, "fixture node count"); let id = l .graph .node_ids() .into_iter() .find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) .unwrap(); assert_eq!( l.graph.get(id).unwrap().core.standard_value("param_a_in", -1), NodeValue::Float(2.5) ); } // Undo to seq 0: an empty project. let session = DatabaseBackend::new(); let at0 = load_at(&session, &uri, &uuid, 0); { let l = at0.lock().unwrap(); assert_eq!(l.graph.node_count(), 0, "empty project at seq 0"); } // Out of range -> E_INVALID. let session = DatabaseBackend::new(); assert_eq!( session .load_at(&StorageUri::parse(&uri).unwrap(), &uuid, 99) .err() .unwrap() .code(), OAKSTORAGE_E_INVALID ); } /// Snapshot pruning keeps the newest [`SNAPSHOT_KEEP`] copies. #[test] fn snapshot_pruning_keeps_three() { let dir = temp_dir("prune"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); with_config("Storage", "SnapshotIntervalSec", 0, || { let project = build_full_project(); let uuid = uuid_of(&project); // 6 commands, each dirty-snapshotted. for i in 0..6i64 { { let mut p = project.lock().unwrap(); let id = p.graph.node_ids()[0]; p.graph .get_mut(id) .unwrap() .core .label = format!("step {i}"); } save_project(&backend, &project, &uri).unwrap(); } let seqs = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) .await .unwrap() .unwrap(); snapshot::Entity::find() .filter(snapshot::Column::ProjectId.eq(proj.id)) .order_by_desc(snapshot::Column::CommandSeq) .all(&conn) .await .unwrap() .into_iter() .map(|s| s.command_seq) .collect::>() }); assert_eq!(seqs.len() as u64, SNAPSHOT_KEEP, "only the newest {SNAPSHOT_KEEP}"); assert_eq!(seqs[0], 6, "the newest survives"); }); } /// Journal retention (`Storage/JournalRetentionDays`): rows older than /// the window and covered by the newest snapshot are dropped; the head /// stays reconstructible (snapshot + remaining rows). #[test] fn journal_retention_truncation() { let dir = temp_dir("retain"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); // Command 1 (import) + command 2 (a value change). save_project(&backend, &project, &uri).unwrap(); { let mut p = project.lock().unwrap(); let math_ids: Vec = p .graph .node_ids() .into_iter() .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); p.graph .get_mut(math_ids[0]) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(3.25)); } save_project(&backend, &project, &uri).unwrap(); // Snapshot at the head so rows ≤ 2 are covered. backend.snapshot(&StorageUri::parse(&uri).unwrap(), &uuid).unwrap(); // Backdate every journal row two days, then save a third command // with a 1-day retention window. inspect_sqlite(&db, |conn| async move { let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2); journal::Entity::update_many() .col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old)) .exec(&conn) .await .unwrap(); }); with_config("Storage", "JournalRetentionDays", 1, || { { let mut p = project.lock().unwrap(); let math_ids: Vec = p .graph .node_ids() .into_iter() .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); p.graph .get_mut(math_ids[0]) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(4.5)); } save_project(&backend, &project, &uri).unwrap(); }); // Only the third command's rows survive. let uuid_q = uuid.clone(); let (pid, rows) = inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) .await .unwrap() .unwrap(); let rows = journal::Entity::find() .filter(journal::Column::ProjectId.eq(proj.id)) .all(&conn) .await .unwrap(); (proj.id, rows) }); let seqs: Vec = rows.iter().map(|r| r.seq).collect(); assert!(!seqs.contains(&1) && !seqs.contains(&2), "old commands pruned: {seqs:?}"); assert_eq!(seqs, vec![3], "only the fresh command remains"); let _ = pid; // The head state is still correct (snapshot at seq 2 + command 3). let session = DatabaseBackend::new(); let (_, loaded) = load_project(&session, &project_uri(&uri, &uuid)); { let l = loaded.lock().unwrap(); let math_ids: Vec = l .graph .node_ids() .into_iter() .filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); assert_eq!( l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), NodeValue::Float(4.5) ); assert_eq!(l.graph.node_count(), 12); } } // --------------------------------------------------------------------------- // Project-manager API surface // --------------------------------------------------------------------------- #[test] fn list_delete_duplicate_rename() { let dir = temp_dir("mgr"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); // Empty library lists nothing. assert!(backend.list_projects(&StorageUri::parse(&uri).unwrap()).unwrap().is_empty()); let alpha = save_named_project(&backend, &uri, "Alpha"); let beta = save_named_project(&backend, &uri, "Beta"); let list = backend.list_projects(&StorageUri::parse(&uri).unwrap()).unwrap(); assert_eq!(list.len(), 2, "two rows"); // Most recently modified first. assert_eq!(list[0].name, "Beta"); assert_eq!(list[1].name, "Alpha"); assert_eq!(list[0].command_seq, 1); assert_eq!(list[0].schema_ver, oaknode::serializer::CURRENT_VERSION.0 as i32); // Rename (library metadata). backend .rename_project(&StorageUri::parse(&uri).unwrap(), &alpha, "Alpha Renamed") .unwrap(); let list = backend.list_projects(&StorageUri::parse(&uri).unwrap()).unwrap(); assert!(list.iter().any(|p| p.name == "Alpha Renamed"), "{list:?}"); assert_eq!( backend .rename_project(&StorageUri::parse(&uri).unwrap(), "{missing}", "X") .err() .unwrap() .code(), OAKSTORAGE_E_NOT_FOUND ); // Duplicate: fresh uuid, default "(copy)" name, history copied. let copy = backend .duplicate_project(&StorageUri::parse(&uri).unwrap(), &beta, None) .unwrap(); assert_ne!(copy.uuid, beta, "fresh uuid"); assert_eq!(copy.name, "Beta (copy)"); assert_eq!(copy.command_seq, 1); let list = backend.list_projects(&StorageUri::parse(&uri).unwrap()).unwrap(); assert_eq!(list.len(), 3); let renamed_copy = backend .duplicate_project(&StorageUri::parse(&uri).unwrap(), &beta, Some("Beta Clone")) .unwrap(); assert_eq!(renamed_copy.name, "Beta Clone"); assert_eq!( backend .duplicate_project(&StorageUri::parse(&uri).unwrap(), "{missing}", None) .err() .unwrap() .code(), OAKSTORAGE_E_NOT_FOUND ); // Delete. for uuid in [&alpha, &beta, ©.uuid, &renamed_copy.uuid] { backend .delete_project(&StorageUri::parse(&uri).unwrap(), uuid) .unwrap(); } assert!(backend.list_projects(&StorageUri::parse(&uri).unwrap()).unwrap().is_empty()); assert_eq!( backend .delete_project(&StorageUri::parse(&uri).unwrap(), "{missing}") .err() .unwrap() .code(), OAKSTORAGE_E_NOT_FOUND ); } /// Duplicating a full-feature project copies the whole history: the copy /// loads identically under its own uuid and keeps the undo history. #[test] fn duplicate_preserves_history() { let dir = temp_dir("dup"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); { let mut p = project.lock().unwrap(); let math_ids: Vec = p .graph .node_ids() .into_iter() .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); p.graph .get_mut(math_ids[0]) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(6.0)); } save_project(&backend, &project, &uri).unwrap(); let copy = backend .duplicate_project(&StorageUri::parse(&uri).unwrap(), &uuid, None) .unwrap(); assert_eq!(copy.command_seq, 2); // The copy loads through a fresh session, field-for-field (uuid is // fresh by design). let session = DatabaseBackend::new(); let (loaded_uuid, loaded) = load_project(&session, &project_uri(&uri, ©.uuid)); assert_eq!(loaded_uuid, copy.uuid); { let o = project.lock().unwrap(); let l = loaded.lock().unwrap(); assert_full_state(&o, &l); } // Undo history travels with the copy: load_at(1) on the copy gives // the pre-change state. let session = DatabaseBackend::new(); let at1 = load_at(&session, &uri, ©.uuid, 1); { let l = at1.lock().unwrap(); let math_ids: Vec = l .graph .node_ids() .into_iter() .filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); assert_eq!( l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), NodeValue::Float(2.5) ); } } /// Manager stats are derived from the node graph, not stored (plan §4). #[test] fn project_stats_derived_from_graph() { let dir = temp_dir("stats"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); let stats = backend .project_stats(&StorageUri::parse(&uri).unwrap(), &uuid) .unwrap(); assert_eq!( stats, ProjectStats { duration_ms: 6000, track_count: 1, clip_count: 2, footage_count: 2, } ); // Same numbers derive_stats yields directly on the live project. let guard = project.lock().unwrap(); assert_eq!(derive_stats(&guard), stats); assert_eq!( backend .project_stats(&StorageUri::parse(&uri).unwrap(), "{missing}") .err() .unwrap() .code(), OAKSTORAGE_E_NOT_FOUND ); } #[test] fn export_and_import_round_trip() { let dir = temp_dir("xi"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); let uuid = uuid_of(&project); save_project(&backend, &project, &uri).unwrap(); // Export: .ove written from an in-memory assembly; the file backend // re-imports it byte-for-byte. let out = dir.join("exported.ove"); let file = file_uri(&out); backend .export_to_file( &StorageUri::parse(&uri).unwrap(), &uuid, &StorageUri::parse(&file).unwrap(), ) .unwrap(); assert!(out.exists(), ".ove written"); let text = std::fs::read_to_string(&out).unwrap(); assert!(text.starts_with(""), "{text}"); let file_backend = Registry::global() .resolve(&StorageUri::parse(&file).unwrap()) .unwrap(); let result = file_backend .load(&StorageUri::parse(&file).unwrap()) .unwrap(); assert_eq!(result.version_info, OAKSTORAGE_OK); let loaded = unsafe { project_arc(&result.project) }.unwrap(); release(result.project); { let o = project.lock().unwrap(); let l = loaded.lock().unwrap(); assert_full_fields(&o, &l); } // Import: the .ove lands as a new library row under a fresh uuid; // importing it again yields a distinct row. let imported = backend .import_from_file( &StorageUri::parse(&uri).unwrap(), &StorageUri::parse(&file).unwrap(), ) .unwrap(); assert_ne!(imported, uuid, "import gets a fresh uuid"); let imported2 = backend .import_from_file( &StorageUri::parse(&uri).unwrap(), &StorageUri::parse(&file).unwrap(), ) .unwrap(); assert_ne!(imported2, imported, "repeat imports are new rows"); let list = backend.list_projects(&StorageUri::parse(&uri).unwrap()).unwrap(); assert_eq!(list.len(), 3, "original + two imports"); let session = DatabaseBackend::new(); let (imported_uuid, imported_proj) = load_project(&session, &project_uri(&uri, &imported)); assert_eq!(imported_uuid, imported); { let l = imported_proj.lock().unwrap(); assert_eq!(l.graph.node_count(), 12); assert_eq!(l.settings.get("projectname").cloned(), Some("full-fixture".to_string())); } // Error paths: unknown project on export; non-file target; corrupt // file on import. assert_eq!( backend .export_to_file( &StorageUri::parse(&uri).unwrap(), "{missing}", &StorageUri::parse(&file).unwrap(), ) .err() .unwrap() .code(), OAKSTORAGE_E_NOT_FOUND ); assert_eq!( backend .export_to_file( &StorageUri::parse(&uri).unwrap(), &uuid, &StorageUri::parse("oakdb+sqlite:///tmp/x.db").unwrap(), ) .err() .unwrap() .code(), OAKSTORAGE_E_INVALID ); let corrupt = dir.join("corrupt.ove"); std::fs::write(&corrupt, "").unwrap(); assert_eq!( backend .import_from_file( &StorageUri::parse(&uri).unwrap(), &StorageUri::parse(&file_uri(&corrupt)).unwrap(), ) .err() .unwrap() .code(), OAKSTORAGE_E_FORMAT ); } // --------------------------------------------------------------------------- // Project selection (`?project=` vs default) // --------------------------------------------------------------------------- #[test] fn project_selection_via_query() { let dir = temp_dir("sel"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let a = save_named_project(&backend, &uri, "A"); let b = save_named_project(&backend, &uri, "B"); // Explicit uuid picks the right row regardless of recency. let session = DatabaseBackend::new(); let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &a)); assert_eq!(loaded_uuid, a); // Default pick = most recently modified (B was written last). let session = DatabaseBackend::new(); let (loaded_uuid, loaded) = load_project(&session, &uri); assert_eq!(loaded_uuid, b); assert_eq!( loaded.lock().unwrap().settings.get("projectname").cloned(), Some("B".to_string()) ); // Unknown uuid -> E_NOT_FOUND; empty library -> E_NOT_FOUND. let session = DatabaseBackend::new(); assert_eq!( session .load(&StorageUri::parse(&project_uri(&uri, "{missing}")).unwrap()) .err() .unwrap() .code(), OAKSTORAGE_E_NOT_FOUND ); } // --------------------------------------------------------------------------- // Error paths, locking, URI matrix // --------------------------------------------------------------------------- /// PG targets are now live: malformed connection strings fail cleanly at /// parse time (E_INVALID, no network touched) and an unreachable server /// fails cleanly at connect (E_IO, no panic). Both run without a real /// PostgreSQL server — the connection-refused case uses port 1 on /// loopback, which nothing listens on. #[test] fn pg_invalid_and_unreachable_targets_error_cleanly() { let dir = temp_dir("pg"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); let project = build_full_project(); save_project(&backend, &project, &uri).unwrap(); let uuid = uuid_of(&project); // Invalid connection strings -> E_INVALID at parse (no connection). for bad in ["oakdb+pg://", "oakdb+pg://?project={x}", "oakdb+pg://user@"] { let err = backend .list_projects(&StorageUri::parse(bad).unwrap()) .err() .unwrap(); assert_eq!(err.code(), OAKSTORAGE_E_INVALID, "{bad}"); } // Unreachable server (port 1 on loopback) -> E_IO at connect, and the // error is clean (no panic) across every operation. The connect probe // fails on the refused TCP connection without waiting out the pool's // acquire timeout. let dead = "oakdb+pg://user@127.0.0.1:1/db"; for call in [ backend.load(&StorageUri::parse(dead).unwrap()).map(|_| ()), save_project(&backend, &project, dead), backend.list_projects(&StorageUri::parse(dead).unwrap()).map(|_| ()), backend.delete_project(&StorageUri::parse(dead).unwrap(), &uuid), backend.rename_project(&StorageUri::parse(dead).unwrap(), &uuid, "X"), backend .duplicate_project(&StorageUri::parse(dead).unwrap(), &uuid, None) .map(|_| ()), backend .export_to_file( &StorageUri::parse(dead).unwrap(), &uuid, &StorageUri::parse("file:///tmp/x.ove").unwrap(), ), backend.snapshot(&StorageUri::parse(dead).unwrap(), &uuid), backend.load_at(&StorageUri::parse(dead).unwrap(), &uuid, 1).map(|_| ()), ] { assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_IO, "dead pg target"); } // The sqlite library next to it is untouched by all of the above. let session = DatabaseBackend::new(); let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &uuid)); assert_eq!(loaded_uuid, uuid); } #[test] fn invalid_uri_matrix() { let dir = temp_dir("uri"); // Relative path / empty body -> E_INVALID. let backend = DatabaseBackend::new(); for bad in ["oakdb+sqlite://relative.db", "oakdb+sqlite://"] { let err = backend .list_projects(&StorageUri::parse(bad).unwrap()) .err() .unwrap(); assert_eq!(err.code(), OAKSTORAGE_E_INVALID, "{bad}"); } // Nonexistent parent directory -> E_IO at connect. let err = backend .list_projects(&StorageUri::parse(&db_uri(&dir.join("no/such/dir/lib.db"))).unwrap()) .err() .unwrap(); assert_eq!(err.code(), OAKSTORAGE_E_IO, "missing parent dir"); // Unknown scheme stays unclaimed by the registry. assert_eq!( Registry::global() .resolve(&StorageUri::parse("oakdb+sqlite3:///tmp/x.db").unwrap()) .err() .unwrap() .code(), OAKSTORAGE_E_NO_BACKEND ); } /// Write failures surface as errors, not panics or silent corruption: /// (a) read-only database files, (b) an out-of-range undo target, and /// (c) concurrent writers on one file. #[test] fn failure_paths_report_cleanly() { let dir = temp_dir("fail"); let db = dir.join("lib.db"); let uri = db_uri(&db); let backend = DatabaseBackend::new(); // (a) Read-only db + WAL files: a fresh backend cannot write. let project = build_full_project(); save_project(&backend, &project, &uri).unwrap(); let mut targets = vec![db.clone()]; for suffix in ["-wal", "-shm"] { let f = PathBuf::from(format!("{}{}", db.display(), suffix)); if f.exists() { targets.push(f); } } let saved = targets .iter() .map(|f| std::fs::metadata(f).unwrap().permissions()) .collect::>(); for f in &targets { let mut p = std::fs::metadata(f).unwrap().permissions(); p.set_readonly(true); std::fs::set_permissions(f, p).unwrap(); } let fresh = DatabaseBackend::new(); let err = save_project(&fresh, &project, &uri).err().unwrap(); assert_eq!(err.code(), OAKSTORAGE_E_IO, "read-only library write must fail"); for (f, p) in targets.iter().zip(saved) { std::fs::set_permissions(f, p).unwrap(); } // (b) Undo beyond the head -> E_INVALID (the head-seq range is // enforced; load_at(0) stays valid). let uuid = uuid_of(&project); let session = DatabaseBackend::new(); assert_eq!( session .load_at(&StorageUri::parse(&uri).unwrap(), &uuid, -1) .err() .unwrap() .code(), OAKSTORAGE_E_INVALID ); } /// Two backends writing different projects to one file concurrently /// complete without deadlock, and a same-uuid write race surfaces a /// clean error instead of corrupting the library. #[test] fn concurrent_writers_are_serialized() { let dir = temp_dir("conc"); let db = dir.join("lib.db"); let uri = db_uri(&db); // Two projects, two threads, different uuids: both writers land. let p1 = build_full_project(); let p2 = build_full_project(); let uri_t1 = uri.clone(); let uri_t2 = uri.clone(); let b1 = DatabaseBackend::new(); let b2 = DatabaseBackend::new(); let t1 = std::thread::spawn(move || { for _ in 0..5 { save_project(&b1, &p1, &uri_t1).unwrap(); } }); let t2 = std::thread::spawn(move || { for _ in 0..5 { save_project(&b2, &p2, &uri_t2).unwrap(); } }); t1.join().unwrap(); t2.join().unwrap(); let list = DatabaseBackend::new() .list_projects(&StorageUri::parse(&uri).unwrap()) .unwrap(); assert_eq!(list.len(), 2, "both projects persisted"); assert!(list.iter().all(|p| p.name == "full-fixture"), "{list:?}"); // Same-uuid race: both threads save projects with the same uuid but // different content, so the writes genuinely contend (on the import // row, or on journal seqs). Results are Ok or a clean error, never a // panic, and the library still loads afterwards. let b3 = DatabaseBackend::new(); let b4 = DatabaseBackend::new(); let p3 = build_full_project(); let p4 = build_full_project(); { let mut g = p4.lock().unwrap(); g.uuid = uuid_of(&p3); // force the same row identity let math_ids: Vec = g .graph .node_ids() .into_iter() .filter(|id| g.graph.get(*id).unwrap().behavior.type_id() == MATH) .collect(); g.graph .get_mut(math_ids[0]) .unwrap() .core .set_standard_value("param_a_in", -1, NodeValue::Float(8.0)); } let same = uuid_of(&p3); let uri_a = uri.clone(); let uri_b = uri.clone(); let ta = std::thread::spawn(move || { (0..3) .map(|_| save_project(&b3, &p3, &uri_a).map_err(|e| e.code())) .collect::>() }); let tb = std::thread::spawn(move || { (0..3) .map(|_| save_project(&b4, &p4, &uri_b).map_err(|e| e.code())) .collect::>() }); let (ra, rb) = (ta.join().unwrap(), tb.join().unwrap()); for (i, r) in ra.iter().chain(rb.iter()).enumerate() { match r { Ok(()) => {} Err(code) => { // A same-uuid/seq insert conflict surfaces as a database // error (or a retried BUSY gives up). assert!( *code == OAKSTORAGE_E_IO || *code == oakstorage::error::OAKSTORAGE_E_FAILED, "writer {i} failed with unexpected code {code}" ); } } } // The library is still consistent: the same-uuid project loads and // the journal's newest command matches the row's head seq. let session = DatabaseBackend::new(); let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &same)); assert_eq!(loaded_uuid, same, "library consistent after races"); let (head, seqs) = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&same)) .one(&conn) .await .unwrap() .unwrap(); let mut seqs: Vec = journal::Entity::find() .filter(journal::Column::ProjectId.eq(proj.id)) .all(&conn) .await .unwrap() .into_iter() .map(|r| r.seq) .collect(); seqs.sort(); (proj.command_seq, seqs) }); assert!(!seqs.is_empty(), "the same-uuid project has commands"); assert_eq!( seqs.last().copied(), Some(head), "head seq matches the newest command (seqs={seqs:?})" ); }