mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-Dhs0pFsV.css
Normal file
1
crates/archivr-server/static/assets/index-Dhs0pFsV.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-j6_-GyNh.js
Normal file
40
crates/archivr-server/static/assets/index-j6_-GyNh.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-DuR832Rs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oE_dvyrb.css">
|
||||
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue