1
Fork 0
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:
TheGeneralist 2026-07-04 13:46:19 +02:00
parent cad3ae9885
commit ed1f883ff1
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
15 changed files with 416 additions and 128 deletions

View file

@ -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);
}
}