Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
25 kB · 730 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731//! Real loopback HTTP regression flow against a disposable PostgreSQL schema.use crate::{notes, reader, reader_generation, reader_native, state::AppState, transfer};use axum::{ Router, routing::{get, post},};use reqwest::{Client, Method, StatusCode};use serde_json::{Value, json};use sqlx::postgres::PgPoolOptions;use std::{ io::{Cursor, Write}, time::Duration,};use uuid::Uuid;
async fn request( client: &Client, base: &str, method: Method, path: &str, body: Value, expected: StatusCode,) -> Value { let response = client .request(method, format!("{base}{path}")) .json(&body) .send() .await .unwrap(); let status = response.status(); let text = response.text().await.unwrap(); assert_eq!(status, expected, "{path}: {text}"); if text.is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap() }}
async fn upload(client: &Client, base: &str, bytes: &[u8], filename: &str) -> Value { let boundary = "native-reader-test-boundary"; let mut body = format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/zip\r\n\r\n").into_bytes(); body.extend_from_slice(bytes); body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); let response = client .post(format!("{base}/api/reader/import")) .header( "content-type", format!("multipart/form-data; boundary={boundary}"), ) .body(body) .send() .await .unwrap(); let status = response.status(); let text = response.text().await.unwrap(); assert!(status.is_success(), "{status}: {text}"); serde_json::from_str(&text).unwrap()}
fn legacy_zip() -> Vec<u8> { let payload = json!({"version":1,"materials":[{"id":"legacy-article","kind":"article","title":"Legacy receipt","body":"# Original legacy\n\n$z$","version":1,"created_at":"2026-10-08T10:00:00Z","updated_at":"2026-10-08T10:00:00Z"}],"messages":[]}); let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new())); archive .start_file("odre-backup.json", zip::write::SimpleFileOptions::default()) .unwrap(); archive .write_all(&serde_json::to_vec(&payload).unwrap()) .unwrap(); archive.finish().unwrap().into_inner()}
#[tokio::test]#[ignore = "requires disposable PostgreSQL via ODRE_TRANSFER_TEST_DATABASE_URL"]async fn postgres_http_native_bridge_history_cas_delete_and_imports() { let url = std::env::var("ODRE_TRANSFER_TEST_DATABASE_URL").expect("use an isolated QA database"); let admin = PgPoolOptions::new() .max_connections(1) .connect(&url) .await .unwrap(); let schema = format!("native_test_{}", Uuid::new_v4().simple()); sqlx::query(&format!("create schema {schema}")) .execute(&admin) .await .unwrap(); let scoped = schema.clone(); let pool = PgPoolOptions::new() .max_connections(5) .after_connect(move |connection, _| { let command = format!("set search_path to {scoped},public"); Box::pin(async move { sqlx::query(&command).execute(connection).await?; Ok(()) }) }) .connect(&url) .await .unwrap(); sqlx::migrate!("../../migrations/platform") .run(&pool) .await .unwrap(); let storage = std::env::temp_dir().join(format!("odre-native-http-test-{}", Uuid::new_v4())); tokio::fs::create_dir_all(&storage).await.unwrap(); let state = AppState { platform_db: pool.clone(), config: crate::config::Config { platform_database_url: url, bind: "127.0.0.1:0".parse().unwrap(), web_origin: "http://localhost".into(), storage_dir: storage.clone(), api_key_encryption_secret: "qa-no-real-credentials".into(), }, }; let old = Uuid::new_v4(); let user_note = Uuid::new_v4(); sqlx::query("insert into notes(id,slug,title,kind,document) values($1,$2,'User-authored original','card',$3)") .bind(user_note).bind(format!("reader-article-{old}")).bind(json!({"content":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Preserve original"}]}]}})).execute(&pool).await.unwrap(); sqlx::query("insert into reader_materials(id,kind,title,body,version) values($1,'article','Backfill','# Current',2)").bind(old).execute(&pool).await.unwrap(); sqlx::query("insert into reader_material_revisions(material_id,version,title,subject,body,sources) values($1,1,'Backfill','','# Previous','[]')").bind(old).execute(&pool).await.unwrap(); reader_native::backfill(&state).await.unwrap(); reader_native::backfill(&state).await.unwrap(); let backfilled: Uuid = sqlx::query_scalar("select note_id from reader_materials where id=$1") .bind(old) .fetch_one(&pool) .await .unwrap(); assert_ne!(backfilled, user_note); let original_title: String = sqlx::query_scalar("select title from notes where id=$1") .bind(user_note) .fetch_one(&pool) .await .unwrap(); assert_eq!(original_title, "User-authored original"); let history_count: i64 = sqlx::query_scalar("select count(*) from note_revisions where note_id=$1") .bind(backfilled) .fetch_one(&pool) .await .unwrap(); assert_eq!(history_count, 2);
let app = Router::new() .route( "/api/notes", get(notes::list_notes).post(notes::create_note), ) .route( "/api/notes/{id}", get(notes::get_note) .patch(notes::update_note) .delete(notes::delete_note), ) .route("/api/notes/{id}/revisions", get(notes::list_revisions)) .route( "/api/notes/{note_id}/revisions/{revision_id}/restore", post(notes::restore_revision), ) .route("/api/transfer/export", get(transfer::export_data)) .route("/api/transfer/import", post(transfer::import_data)) .merge(reader::routes()) .merge(reader_generation::routes()) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); let client = Client::builder() .timeout(Duration::from_secs(10)) .build() .unwrap(); let sources = json!([{"title":"Source","url":"https://example.com/source","excerpt":"Saved source text"}]); let material = request(&client,&base,Method::POST,"/api/reader/materials",json!({"title":"Bridge","content":"# Bridge\n\nOriginal $x$\n\n```mermaid\ngraph TD; A-->B\n```","sources":sources}),StatusCode::CREATED).await; let mid = material["id"].as_str().unwrap(); let nid = material["note_id"] .as_str() .expect("article creation returns populated note_id"); let chat = Uuid::new_v4(); sqlx::query("insert into agent_conversations(id,context_type,context_id,title,provider,model) values($1,'reader',$2,'Preserved chat','openai','qa')") .bind(chat).bind(Uuid::parse_str(mid).unwrap()).execute(&pool).await.unwrap(); sqlx::query( "insert into reader_highlights(material_id,quote,content_version) values($1,'Original',1)", ) .bind(Uuid::parse_str(mid).unwrap()) .execute(&pool) .await .unwrap(); sqlx::query("insert into reader_exercise_attempts(material_id,exercise_key,answer) values($1,'saved-exercise','42')").bind(Uuid::parse_str(mid).unwrap()).execute(&pool).await.unwrap(); let note = request( &client, &base, Method::GET, &format!("/api/notes/{nid}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(note["reader_material_id"], mid); let shelf_count: i64 = sqlx::query_scalar( "select count(*) from shelf_items where metadata->>'reader_material_id'=$1", ) .bind(mid) .fetch_one(&pool) .await .unwrap(); assert_eq!(shelf_count, 0); request( &client, &base, Method::PATCH, &format!("/api/reader/materials/{mid}"), json!({"position":{"scrollRatio":0.4}}), StatusCode::OK, ) .await; let mut document = note["document"].clone(); document["content"]["content"].as_array_mut().unwrap().push(json!({"type":"paragraph","content":[{"type":"text","text":"Anchored","marks":[{"type":"annotationAnchor","attrs":{"annotationId":"kept-anchor"}},{"type":"link","attrs":{"href":"https://example.com/link"}}]}]})); let edited = request(&client,&base,Method::PATCH,&format!("/api/notes/{nid}"),json!({"expected_version":1,"document":document,"excerpt":"User description","kind":"card"}),StatusCode::OK).await; assert_eq!(edited["version"], 2); assert_eq!(edited["kind"], "card"); assert_eq!(edited["excerpt"], "User description"); let current = request( &client, &base, Method::GET, &format!("/api/reader/materials/{mid}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(current["version"], 2); assert_eq!(current["sources"], sources); assert_eq!(current["position"]["scrollRatio"], 0.4); assert!( current["body"] .as_str() .unwrap() .contains("https://example.com/link") ); request( &client, &base, Method::PATCH, &format!("/api/reader/materials/{mid}"), json!({"expected_version":1,"content":"Stale"}), StatusCode::CONFLICT, ) .await; request( &client, &base, Method::PATCH, &format!("/api/notes/{nid}"), json!({"expected_version":1,"title":"Stale"}), StatusCode::CONFLICT, ) .await; let proposal = Uuid::new_v4(); sqlx::query("insert into reader_rewrite_proposals(id,material_id,operation,instruction,title,replacement,base_version,status,provider,model) values($1,$2,'rewrite','Test','Bridge','Replacement',1,'complete','openai','qa')").bind(proposal).bind(Uuid::parse_str(mid).unwrap()).execute(&pool).await.unwrap(); request( &client, &base, Method::POST, &format!("/api/reader/proposals/{proposal}/apply"), json!({"expected_version":2}), StatusCode::CONFLICT, ) .await; let rewritten = current["body"] .as_str() .unwrap() .replace("Original", "Changed"); sqlx::query("update reader_rewrite_proposals set base_version=2,replacement=$2 where id=$1") .bind(proposal) .bind(&rewritten) .execute(&pool) .await .unwrap(); let applied = request( &client, &base, Method::POST, &format!("/api/reader/proposals/{proposal}/apply"), json!({"expected_version":2}), StatusCode::OK, ) .await; assert_eq!(applied["version"], 3); assert_eq!(applied["note_id"], nid); let after_rewrite = request( &client, &base, Method::GET, &format!("/api/notes/{nid}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(after_rewrite["version"], 3); assert!( after_rewrite["document"] .to_string() .contains("kept-anchor") ); let old_revision = request( &client, &base, Method::GET, &format!("/api/notes/{nid}/revisions"), Value::Null, StatusCode::OK, ) .await .as_array() .unwrap() .iter() .find(|r| r["revision_number"] == 1) .unwrap()["id"] .as_str() .unwrap() .to_owned(); let restored = request( &client, &base, Method::POST, &format!("/api/notes/{nid}/revisions/{old_revision}/restore"), json!({"expected_version":3}), StatusCode::OK, ) .await; assert_eq!(restored["version"], 4); let restored_material = request( &client, &base, Method::GET, &format!("/api/reader/materials/{mid}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(restored_material["version"], 4); assert_eq!(restored_material["sources"], sources); let revisions = request( &client, &base, Method::GET, &format!("/api/reader/materials/{mid}/revisions"), Value::Null, StatusCode::OK, ) .await; let revision = revisions .as_array() .unwrap() .iter() .find(|r| r["version"] == 2) .unwrap()["id"] .as_str() .unwrap(); let restored_again = request( &client, &base, Method::POST, &format!("/api/reader/materials/{mid}/revisions/{revision}/restore"), json!({"expected_version":4}), StatusCode::OK, ) .await; assert_eq!(restored_again["version"], 5);
let annotation = Uuid::new_v4(); let native_document = json!({"schema_version":1,"content":{"type":"doc","content":[{"type":"paragraph","attrs":{"id":"stable-block"},"content":[{"type":"text","text":"Native underline","marks":[{"type":"underline"},{"type":"annotationAnchor","attrs":{"annotationId":annotation}}]}]}]}}); let card = request( &client, &base, Method::POST, "/api/notes", json!({"title":"Native card","kind":"card","document":native_document,"tags":["Native tag"]}), StatusCode::OK, ) .await; let card_id = card["id"].as_str().unwrap(); sqlx::query("insert into annotations(id,note_id,block_id,anchor,messages) values($1,$2,'stable-block',$3,$4)") .bind(annotation).bind(Uuid::parse_str(card_id).unwrap()).bind(json!({"quote":"Native underline"})).bind(json!([{"body":"Saved annotation"}])).execute(&pool).await.unwrap(); let attached = request( &client, &base, Method::POST, &format!("/api/reader/from-note/{card_id}"), json!({}), StatusCode::OK, ) .await; let attached_again = request( &client, &base, Method::POST, &format!("/api/reader/from-note/{card_id}"), json!({}), StatusCode::OK, ) .await; assert_eq!(attached["id"], attached_again["id"]); let card_after = request( &client, &base, Method::GET, &format!("/api/notes/{card_id}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(card_after["document"], native_document); assert_eq!(card_after["version"], 1); let unsupported = json!({"schema_version":1,"content":{"type":"doc","content":[{"type":"excalidrawDiagram","attrs":{"documentId":"preserved"}}]}}); let spatial = request( &client, &base, Method::POST, "/api/notes", json!({"title":"Spatial original","document":unsupported}), StatusCode::OK, ) .await; let spatial_id = spatial["id"].as_str().unwrap(); request( &client, &base, Method::POST, &format!("/api/reader/from-note/{spatial_id}"), json!({}), StatusCode::BAD_REQUEST, ) .await; let spatial_after = request( &client, &base, Method::GET, &format!("/api/notes/{spatial_id}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(spatial_after["document"], unsupported); assert_eq!(spatial_after["version"], 1); let space = Uuid::new_v4(); let lesson = Uuid::new_v4(); sqlx::query("insert into learning_spaces(id,kind,title) values($1,'course','Native course')") .bind(space) .execute(&pool) .await .unwrap(); sqlx::query( "insert into learning_lessons(id,space_id,title,document) values($1,$2,'Native lesson',$3)", ) .bind(lesson) .bind(space) .bind(&native_document) .execute(&pool) .await .unwrap(); let lesson_material = request( &client, &base, Method::POST, &format!("/api/reader/from-learning/{lesson}"), json!({}), StatusCode::OK, ) .await; let lesson_note = lesson_material["note_id"].as_str().unwrap(); let lesson_native = request( &client, &base, Method::GET, &format!("/api/notes/{lesson_note}"), Value::Null, StatusCode::OK, ) .await; assert_eq!(lesson_native["document"], native_document); let again = request( &client, &base, Method::POST, &format!("/api/reader/from-learning/{lesson}"), json!({}), StatusCode::OK, ) .await; assert_eq!(again["id"], lesson_material["id"]); request( &client, &base, Method::DELETE, &format!("/api/notes/{lesson_note}"), Value::Null, StatusCode::CONFLICT, ) .await; request( &client, &base, Method::DELETE, &format!( "/api/reader/materials/{}", lesson_material["id"].as_str().unwrap() ), Value::Null, StatusCode::CONFLICT, ) .await; let history: Value = sqlx::query_scalar( "select document from agent_lesson_revisions where lesson_id=$1 and version=1", ) .bind(lesson) .fetch_one(&pool) .await .unwrap(); assert_eq!(history, native_document);
// Full transfer preserves all IDs and associations, including untouched // native-only spatial content and reader state. Repeated restore is safe. let exported = request( &client, &base, Method::GET, "/api/transfer/export", Value::Null, StatusCode::OK, ) .await; request( &client, &base, Method::POST, "/api/transfer/import", exported.clone(), StatusCode::OK, ) .await; request( &client, &base, Method::POST, "/api/transfer/import", exported, StatusCode::OK, ) .await; let count: i64 = sqlx::query_scalar("select count(*) from reader_materials where note_id=$1") .bind(Uuid::parse_str(card_id).unwrap()) .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 1); // Legacy platform exports omit note_id. An attached native Card keeps its // original identity instead of gaining a duplicate imported Post. let legacy_full = json!({"format":"forge-platform-export","version":1,"data":{"reader_materials":[{"id":attached["id"],"kind":"article","title":"Legacy attached","body":"Old projection","version":1}]}}); request( &client, &base, Method::POST, "/api/transfer/import", legacy_full.clone(), StatusCode::OK, ) .await; request( &client, &base, Method::POST, "/api/transfer/import", legacy_full, StatusCode::OK, ) .await; let still_attached = request( &client, &base, Method::GET, &format!("/api/reader/materials/{}", attached["id"].as_str().unwrap()), Value::Null, StatusCode::OK, ) .await; assert_eq!(still_attached["note_id"], card_id); let before: i64 = sqlx::query_scalar("select count(*) from notes") .fetch_one(&pool) .await .unwrap(); let zip = legacy_zip(); let first = upload(&client, &base, &zip, "legacy.zip").await; let second = upload(&client, &base, &zip, "legacy.zip").await; assert_eq!(first, second); let after: i64 = sqlx::query_scalar("select count(*) from notes") .fetch_one(&pool) .await .unwrap(); assert_eq!(after, before + 1); let receipts: i64 = sqlx::query_scalar("select count(*) from reader_legacy_imports") .fetch_one(&pool) .await .unwrap(); assert_eq!(receipts, 1);
let backup_response = client .get(format!("{base}/api/reader/backup")) .send() .await .unwrap(); assert_eq!(backup_response.status(), StatusCode::OK); let backup_zip = backup_response.bytes().await.unwrap(); let restored_backup = upload(&client, &base, &backup_zip, "native-backup.zip").await; let duplicate_backup = upload(&client, &base, &backup_zip, "native-backup.zip").await; assert_eq!(restored_backup, duplicate_backup); let imported_card: Value = sqlx::query_scalar( "select to_jsonb(n) from notes n where n.title='Native card' and n.id<>$1", ) .bind(Uuid::parse_str(card_id).unwrap()) .fetch_one(&pool) .await .unwrap(); assert_eq!(imported_card["kind"], "card"); let restored_annotation: Value = sqlx::query_scalar("select to_jsonb(a) from annotations a where note_id=$1") .bind(Uuid::parse_str(imported_card["id"].as_str().unwrap()).unwrap()) .fetch_one(&pool) .await .unwrap(); let mut expected_document = native_document.clone(); expected_document["content"]["content"][0]["content"][0]["marks"][1]["attrs"]["annotationId"] = restored_annotation["id"].clone(); assert_eq!(imported_card["document"], expected_document); assert_eq!( restored_annotation["messages"], json!([{"body":"Saved annotation"}]) ); let imported_history: i64 = sqlx::query_scalar("select count(*) from note_revisions where note_id=$1") .bind(Uuid::parse_str(imported_card["id"].as_str().unwrap()).unwrap()) .fetch_one(&pool) .await .unwrap(); assert_eq!(imported_history, 1); let imported_tags:i64=sqlx::query_scalar("select count(*) from note_tags nt join tags t on t.id=nt.tag_id where nt.note_id=$1 and t.display_name='Native tag'") .bind(Uuid::parse_str(imported_card["id"].as_str().unwrap()).unwrap()).fetch_one(&pool).await.unwrap(); assert_eq!(imported_tags, 1); let saved_chat: bool = sqlx::query_scalar( "select exists(select 1 from agent_conversations where id=$1 and context_id=$2)", ) .bind(chat) .bind(Uuid::parse_str(mid).unwrap()) .fetch_one(&pool) .await .unwrap(); assert!(saved_chat); let saved_highlight: i64 = sqlx::query_scalar( "select count(*) from reader_highlights where material_id=$1 and quote='Original'", ) .bind(Uuid::parse_str(mid).unwrap()) .fetch_one(&pool) .await .unwrap(); assert_eq!(saved_highlight, 1); let saved_answer:String=sqlx::query_scalar("select answer from reader_exercise_attempts where material_id=$1 and exercise_key='saved-exercise'").bind(Uuid::parse_str(mid).unwrap()).fetch_one(&pool).await.unwrap(); assert_eq!(saved_answer, "42");
sqlx::query("delete from learning_lessons where id=$1") .bind(lesson) .execute(&pool) .await .unwrap(); request( &client, &base, Method::DELETE, &format!("/api/notes/{lesson_note}"), Value::Null, StatusCode::OK, ) .await; request( &client, &base, Method::GET, &format!( "/api/reader/materials/{}", lesson_material["id"].as_str().unwrap() ), Value::Null, StatusCode::NOT_FOUND, ) .await; request( &client, &base, Method::DELETE, &format!("/api/reader/materials/{mid}"), Value::Null, StatusCode::NO_CONTENT, ) .await; request( &client, &base, Method::GET, &format!("/api/notes/{nid}"), Value::Null, StatusCode::NOT_FOUND, ) .await; let deleted_chat: bool = sqlx::query_scalar("select exists(select 1 from agent_conversations where id=$1)") .bind(chat) .fetch_one(&pool) .await .unwrap(); assert!(!deleted_chat); server.abort(); pool.close().await; sqlx::query(&format!("drop schema {schema} cascade")) .execute(&admin) .await .unwrap(); tokio::fs::remove_dir_all(storage).await.unwrap();}