mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
feat: implement entry deletion
- database: cascade_cached_bytes_after_subtree_delete — one-pass set-aware SQL that excludes the entire subtree simultaneously, avoiding sibling-blob cross-counting bug - database: delete_entry — collects subtree IDs, runs set-aware cascade, NULLs archive_run_items.produced_entry_id (FK blocker), deletes children then root; ON DELETE CASCADE handles artifacts/tags/collections - server: DELETE /api/archives/:archive_id/entries/:entry_uid route, wrapped in a transaction for atomicity - frontend: deleteEntry API call, handleEntryDeleted callback in App.jsx (optimistic list removal + selection clear), Delete entry button in ContextRail with confirm guard - tests: 4 database tests (unknown uid, subtree removal, run_item nulling, cached_bytes recalculation) + 3 route tests (204+gone, 404, 401)
This commit is contained in:
parent
cad3ae9885
commit
ed1f883ff1
15 changed files with 416 additions and 128 deletions
|
|
@ -215,7 +215,7 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid",
|
||||
get(entry_detail).patch(patch_entry_handler),
|
||||
get(entry_detail).patch(patch_entry_handler).delete(delete_entry_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||
|
|
@ -615,6 +615,25 @@ async fn patch_entry_handler(
|
|||
}
|
||||
}
|
||||
|
||||
async fn delete_entry_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth_user.require_role(ROLE_USER)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let mut conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
// Transaction: if any step fails (cascade update, FK null, or delete), nothing is committed.
|
||||
let tx = conn.transaction()?;
|
||||
let found = database::delete_entry(&tx, &entry_uid)?;
|
||||
tx.commit()?;
|
||||
if found {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(ApiError::not_found("entry not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CaptureBody {
|
||||
locator: String,
|
||||
|
|
@ -2958,4 +2977,72 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_entry_returns_204_and_entry_is_gone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_test_entry(&archive_path);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
// Confirm the row is actually gone from the DB.
|
||||
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||
let exists: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||
[&entry.entry_uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert!(exists.is_none(), "entry row should be deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_entry_returns_404_for_missing_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/archives/test/entries/entry_doesnotexist")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_entry_requires_auth() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let entry = make_test_entry(&archive_path);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue