Compare commits
1 Commits
us/US05-01
...
us/US04-03
| Author | SHA1 | Date | |
|---|---|---|---|
| 4735013278 |
34
README.md
34
README.md
@@ -102,37 +102,3 @@ work_item/scripts/python -m pytest tests/e2e -m phase_c -q
|
||||
The deterministic naming provider is enabled only by test configuration
|
||||
(`PHOTO_PIPELINE_FAKE_NAMING_LOG`); without it the application falls back to the
|
||||
offline naming-policy name. Phase A and B suites remain green in the full run above.
|
||||
|
||||
### Phase D acceptance gate
|
||||
|
||||
Phase D (Epic E04: guarded renaming) is the first phase that changes the library on
|
||||
disk, so its gate is the strictest. One command runs the rename API journeys, the
|
||||
filesystem fault injection, and the browser suite:
|
||||
|
||||
```bash
|
||||
work_item/scripts/python -m pytest tests/e2e -m phase_d -q
|
||||
```
|
||||
|
||||
- `tests/e2e/test_phase_d_pipeline.py` drives a real server over HTTP: plan and
|
||||
export, confirmation with the plan version and checksum (a stale token is refused
|
||||
without touching disk), a valid apply, the case-only rename procedure, a collision
|
||||
whose occupant survives, a source that changed after planning, and durability
|
||||
across a full restart.
|
||||
- **Fault injection is real.** `PHOTO_PIPELINE_FAULT_AFTER=<journal state>` kills the
|
||||
server process the instant that state is persisted. The suite crashes it at every
|
||||
journal transition in turn (`moving`, `moved`, `database_updated`, `verified`),
|
||||
starts a fresh process against the same database and library, and requires recovery
|
||||
to converge from journal and disk evidence alone — with the asset set, the stable
|
||||
IDs, and every content hash unchanged. Ambiguous evidence is never guessed: it stays
|
||||
classified `manual` and keeps blocking. An unresolved rename is the cancellation
|
||||
boundary — there is no cancel once a run starts, and unrelated mutations (album
|
||||
proposal generation and approval) are refused with 409 `rename_recovery_required`
|
||||
until it is resolved, while reads stay available.
|
||||
- `tests/e2e/test_renames_ui.py` covers the browser journeys: preview of every
|
||||
affected path, confirmation carrying the server-issued token, apply with progress
|
||||
and terminal verification, stale confirmation, collision, interruption, recovery,
|
||||
rollback, keyboard confirmation, and the view still matching the journal after a
|
||||
server restart.
|
||||
|
||||
The fault barrier is test-only configuration; without `PHOTO_PIPELINE_FAULT_AFTER`
|
||||
the apply path has no crash points. Phases A–C remain green in the full run above.
|
||||
|
||||
@@ -165,11 +165,6 @@ a.link:hover { text-decoration: underline; }
|
||||
.badge.approved { color: var(--ok); border-color: var(--ok); }
|
||||
.badge.error { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* Renames: the preview table must show whole paths, so let them wrap rather than
|
||||
truncate — a hidden path segment is exactly what makes a move a surprise. */
|
||||
.grid th, .grid td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.path { font-family: ui-monospace, monospace; font-size: 0.85em; word-break: break-all; }
|
||||
|
||||
/* Narrow screens: stack the panes so blockers and actions stay reachable. */
|
||||
@media (max-width: 720px) {
|
||||
.two-pane { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<a href="#/library" data-nav="library">Library</a>
|
||||
<a href="#/analyze" data-nav="analyze">Analyze</a>
|
||||
<a href="#/albums" data-nav="albums">Albums</a>
|
||||
<a href="#/renames" data-nav="renames">Renames</a>
|
||||
<a href="#/stats" data-nav="stats">Stats</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -99,19 +99,4 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
...opts,
|
||||
}),
|
||||
|
||||
// ── Renames: plan, confirm, apply, recover ──────────────────────────────
|
||||
listPlans: (opts = {}) => request("/rename-plans", opts),
|
||||
buildPlan: (opts = {}) => request("/rename-plans", { method: "POST", ...opts }),
|
||||
getPlan: (id, opts = {}) => request(`/rename-plans/${encodeURIComponent(id)}`, opts),
|
||||
applyPlan: (id, payload, opts = {}) =>
|
||||
request(`/rename-plans/${encodeURIComponent(id)}/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
...opts,
|
||||
}),
|
||||
rollbackPlan: (id, opts = {}) =>
|
||||
request(`/rename-plans/${encodeURIComponent(id)}/rollback`, { method: "POST", ...opts }),
|
||||
renameRecovery: (opts = {}) => request("/rename-recovery", opts),
|
||||
resolveRecovery: (opts = {}) => request("/rename-recovery/resolve", { method: "POST", ...opts }),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { api } from "./api.js";
|
||||
import { navigate, onRouteChange, parseHash } from "./router.js";
|
||||
import { renderRenames, setRenamesRender } from "./renames.js";
|
||||
import {
|
||||
renderAlbums,
|
||||
renderAnalyze,
|
||||
@@ -363,13 +362,11 @@ function render() {
|
||||
else if (path === "/library") renderLibrary(root, params);
|
||||
else if (path === "/analyze") renderAnalyze(root, params);
|
||||
else if (path === "/albums") renderAlbums(root, params);
|
||||
else if (path === "/renames") renderRenames(root, params);
|
||||
else if (path === "/stats") renderStats(root, params);
|
||||
else show(errorBanner("Unknown view"));
|
||||
}
|
||||
|
||||
// Let views re-render the current route after a mutation.
|
||||
setRender(render);
|
||||
setRenamesRender(render);
|
||||
onRouteChange(render);
|
||||
render();
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
// Renames view (US04-05): preview a rename plan, confirm it with the server-issued
|
||||
// token, watch it run, and resolve anything an interruption left behind.
|
||||
//
|
||||
// Nothing here decides what is safe. Blocking issues, plan applicability, and
|
||||
// recovery classifications all come from the server; the view only shows them and
|
||||
// refuses to offer an action the server would reject.
|
||||
import { api } from "./api.js";
|
||||
import { el, errorBanner, setActiveNav } from "./dom.js";
|
||||
|
||||
// What the last confirm did, for this tab only. Deliberately not persisted: after a
|
||||
// reload the page must show what the journal says, not what this page remembers.
|
||||
let outcome = null;
|
||||
|
||||
let render = () => {};
|
||||
export function setRenamesRender(fn) {
|
||||
render = fn;
|
||||
}
|
||||
|
||||
const BLOCKED_TITLE = "an unresolved rename must be resolved first";
|
||||
|
||||
export async function renderRenames(root, params = {}) {
|
||||
setActiveNav("renames");
|
||||
let plans, recovery;
|
||||
try {
|
||||
[plans, recovery] = await Promise.all([api.listPlans(), api.renameRecovery()]);
|
||||
} catch (error) {
|
||||
root.replaceChildren(errorBanner(`Failed to load renames: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedId = params.plan || (plans.items[0] && plans.items[0].id) || null;
|
||||
let plan = null;
|
||||
if (selectedId) {
|
||||
try {
|
||||
plan = await api.getPlan(selectedId);
|
||||
} catch (error) {
|
||||
root.replaceChildren(errorBanner(`Failed to load plan: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const blocked = recovery.blocks_mutation;
|
||||
root.replaceChildren(
|
||||
el("h1", {}, "Renames"),
|
||||
recoveryPanel(recovery),
|
||||
el(
|
||||
"div",
|
||||
{ class: "toolbar" },
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "build-plan",
|
||||
disabled: blocked ? "disabled" : false,
|
||||
title: blocked ? BLOCKED_TITLE : false,
|
||||
onclick: () => run(() => api.buildPlan(), "Planning…"),
|
||||
},
|
||||
"Build plan from approved names"
|
||||
)
|
||||
),
|
||||
outcomeBanner(),
|
||||
plan
|
||||
? planSection(plan, blocked)
|
||||
: el(
|
||||
"p",
|
||||
{ class: "muted", "data-testid": "no-plan" },
|
||||
"No rename plan yet — approve album names, then build a plan to preview the moves."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── preview ──────────────────────────────────────────────────────────────────
|
||||
function planSection(plan, blocked) {
|
||||
const blockingIssues = plan.operations.flatMap((operation) =>
|
||||
operation.issues.filter((issue) => issue.blocking)
|
||||
);
|
||||
|
||||
return el(
|
||||
"div",
|
||||
{ "data-testid": "plan" },
|
||||
el(
|
||||
"div",
|
||||
{ class: "decision-bar" },
|
||||
el("span", { class: `badge ${plan.state}`, "data-testid": "plan-state" }, plan.state),
|
||||
el(
|
||||
"span",
|
||||
{ class: "badge", "data-testid": "operation-count" },
|
||||
`${plan.operation_count} folder${plan.operation_count === 1 ? "" : "s"}`
|
||||
),
|
||||
el(
|
||||
"span",
|
||||
{ class: "badge", "data-testid": "affected-assets" },
|
||||
`${plan.operations.reduce((sum, o) => sum + o.asset_count, 0)} photos`
|
||||
),
|
||||
blockingIssues.length
|
||||
? el(
|
||||
"span",
|
||||
{ class: "badge attention", "data-testid": "blocking-count" },
|
||||
`${blockingIssues.length} blocking issue${blockingIssues.length === 1 ? "" : "s"}`
|
||||
)
|
||||
: null
|
||||
),
|
||||
plan.blockers.length
|
||||
? el(
|
||||
"div",
|
||||
{ class: "alert", role: "alert", "data-testid": "plan-blockers" },
|
||||
`This plan cannot run: ${plan.blockers.join("; ")}`
|
||||
)
|
||||
: null,
|
||||
operationsTable(plan),
|
||||
confirmBlock(plan, blocked)
|
||||
);
|
||||
}
|
||||
|
||||
function operationType(operation) {
|
||||
if (operation.case_only) return "case-only rename";
|
||||
if (!operation.same_filesystem) return "cross-filesystem move";
|
||||
return "rename";
|
||||
}
|
||||
|
||||
function operationsTable(plan) {
|
||||
if (!plan.operations.length) {
|
||||
return el("p", { class: "muted", "data-testid": "no-operations" }, "This plan moves nothing.");
|
||||
}
|
||||
return el(
|
||||
"table",
|
||||
{ class: "grid", "data-testid": "operations" },
|
||||
el(
|
||||
"thead",
|
||||
{},
|
||||
el(
|
||||
"tr",
|
||||
{},
|
||||
...["#", "Album", "From", "To", "Type", "Photos", "Issues", "State"].map((label) =>
|
||||
el("th", { scope: "col" }, label)
|
||||
)
|
||||
)
|
||||
),
|
||||
el(
|
||||
"tbody",
|
||||
{},
|
||||
...plan.operations.map((operation) =>
|
||||
el(
|
||||
"tr",
|
||||
{ "data-testid": "operation-row", "data-album": operation.album },
|
||||
el("td", {}, String(operation.sequence)),
|
||||
el("td", { "data-testid": "op-album" }, operation.album),
|
||||
// Full paths, not basenames: the whole point of the preview is that no
|
||||
// move is a surprise.
|
||||
el("td", { class: "path", "data-testid": "op-source" }, operation.source_path),
|
||||
el("td", { class: "path", "data-testid": "op-destination" }, operation.destination_path),
|
||||
el("td", { "data-testid": "op-type" }, operationType(operation)),
|
||||
el("td", { "data-testid": "op-count" }, String(operation.asset_count)),
|
||||
el(
|
||||
"td",
|
||||
{ "data-testid": "op-issues" },
|
||||
operation.issues.length
|
||||
? operation.issues.map((issue) =>
|
||||
el(
|
||||
"span",
|
||||
{
|
||||
class: `badge ${issue.blocking ? "attention" : ""}`,
|
||||
"data-testid": "op-issue",
|
||||
"data-code": issue.code,
|
||||
title: issue.message,
|
||||
},
|
||||
`${issue.blocking ? "blocks" : "note"}: ${issue.code}`
|
||||
)
|
||||
)
|
||||
: el("span", { class: "muted" }, "—")
|
||||
),
|
||||
el(
|
||||
"td",
|
||||
{},
|
||||
el("span", { "data-testid": "op-state" }, operation.journal_state),
|
||||
operation.verified_at
|
||||
? el("span", { class: "badge complete", "data-testid": "op-verified" }, "verified")
|
||||
: null,
|
||||
operation.error_code
|
||||
? el(
|
||||
"div",
|
||||
{ class: "blocker", "data-testid": "op-error" },
|
||||
`${operation.error_code}: ${operation.error_message || ""}`
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── confirmation ─────────────────────────────────────────────────────────────
|
||||
// Journal states whose content can still be moved back; `complete` is not one of
|
||||
// them (photo_pipeline/services/rename_journal.py: complete is terminal).
|
||||
const REVERSIBLE = new Set(["moved", "database_updated", "verified", "rollback_required"]);
|
||||
|
||||
function rollbackable(plan) {
|
||||
return plan.operations.some((operation) => REVERSIBLE.has(operation.journal_state));
|
||||
}
|
||||
|
||||
function confirmBlock(plan, blocked) {
|
||||
const applyable = plan.applicable && !blocked;
|
||||
return el(
|
||||
"div",
|
||||
{ class: "card", "data-testid": "confirm" },
|
||||
el("h2", {}, "Confirm"),
|
||||
// The token is shown, not just sent: a confirmation the user cannot see is a
|
||||
// confirmation they cannot check against what the preview claimed.
|
||||
el(
|
||||
"p",
|
||||
{ class: "muted", "data-testid": "confirm-token" },
|
||||
`Plan ${plan.id} · version ${plan.version} · checksum ${plan.checksum.slice(0, 12)}`
|
||||
),
|
||||
el(
|
||||
"p",
|
||||
{ "data-testid": "cancel-note" },
|
||||
"Once confirmed, the run cannot be cancelled part-way: each folder is moved, " +
|
||||
"verified, and recorded before the next one starts. An interruption is " +
|
||||
"recoverable from the journal — it is never left half-applied."
|
||||
),
|
||||
el(
|
||||
"div",
|
||||
{ class: "toolbar" },
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "apply-plan",
|
||||
disabled: applyable ? false : "disabled",
|
||||
title: blocked
|
||||
? BLOCKED_TITLE
|
||||
: plan.applicable
|
||||
? false
|
||||
: `plan is ${plan.state} — it cannot be applied`,
|
||||
onclick: () =>
|
||||
run(
|
||||
() =>
|
||||
api.applyPlan(plan.id, {
|
||||
expected_version: plan.version,
|
||||
expected_checksum: plan.checksum,
|
||||
}),
|
||||
`Applying ${plan.operation_count} rename(s)…`
|
||||
),
|
||||
},
|
||||
`Apply ${plan.operation_count} rename(s)`
|
||||
),
|
||||
// Rollback is recovery, not undo: a completed rename is terminal and stays
|
||||
// that way. The button appears only while operations are still reversible.
|
||||
rollbackable(plan)
|
||||
? el(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "rollback-plan",
|
||||
onclick: () => run(() => api.rollbackPlan(plan.id), "Rolling back…"),
|
||||
},
|
||||
"Roll back the unfinished moves"
|
||||
)
|
||||
: null
|
||||
),
|
||||
el("p", { role: "status", "aria-live": "polite", "data-testid": "apply-progress" }, "")
|
||||
);
|
||||
}
|
||||
|
||||
// Runs a mutating call, showing progress while it is in flight and leaving the
|
||||
// result — including a stale-confirmation conflict — visible afterwards.
|
||||
async function run(action, progressText) {
|
||||
const progress = document.querySelector('[data-testid="apply-progress"]');
|
||||
if (progress) progress.textContent = progressText;
|
||||
try {
|
||||
outcome = { kind: "ok", result: await action() };
|
||||
} catch (error) {
|
||||
outcome = error.status === 409 ? { kind: "conflict" } : { kind: "error", error };
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function outcomeBanner() {
|
||||
if (!outcome) return null;
|
||||
if (outcome.kind === "conflict") {
|
||||
return el(
|
||||
"div",
|
||||
{ class: "alert", role: "alert", "data-testid": "conflict" },
|
||||
"This plan changed on the server since it was loaded. Nothing was applied; " +
|
||||
"the preview below is the current state — review it and confirm again."
|
||||
);
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
return el(
|
||||
"div",
|
||||
{ class: "alert", role: "alert", "data-testid": "apply-error" },
|
||||
`Failed: ${outcome.error.message}`
|
||||
);
|
||||
}
|
||||
const result = outcome.result;
|
||||
if (result.applied === undefined) return null; // a build or recovery call
|
||||
return el(
|
||||
"div",
|
||||
{ class: "alert", role: "status", "data-testid": "apply-result" },
|
||||
`Applied ${result.applied}, failed ${result.failed}, skipped ${result.skipped}. ` +
|
||||
`The plan is now ${result.state}.`
|
||||
);
|
||||
}
|
||||
|
||||
// ── recovery ─────────────────────────────────────────────────────────────────
|
||||
function recoveryPanel(recovery) {
|
||||
if (!recovery.items.length) return null;
|
||||
const recoverable = recovery.items.filter((item) => item.classification !== "manual");
|
||||
return el(
|
||||
"div",
|
||||
{ class: "alert", role: "alert", "data-testid": "recovery-banner" },
|
||||
el(
|
||||
"strong",
|
||||
{},
|
||||
recovery.blocks_mutation
|
||||
? "A rename was interrupted — other changes are paused"
|
||||
: "A rename is unresolved"
|
||||
),
|
||||
el(
|
||||
"p",
|
||||
{ "data-testid": "recovery-explanation" },
|
||||
recovery.blocks_mutation
|
||||
? "Some folders on disk may disagree with the database. Planning, applying, and " +
|
||||
"album-name changes stay blocked until this is resolved; browsing is unaffected."
|
||||
: "These operations never reached a terminal state."
|
||||
),
|
||||
el(
|
||||
"ul",
|
||||
{},
|
||||
...recovery.items.map((item) =>
|
||||
el(
|
||||
"li",
|
||||
{ "data-testid": "recovery-item", "data-classification": item.classification },
|
||||
`${item.album}: ${item.journal_state} — ${item.classification} — ${item.reason} ` +
|
||||
`(${item.source_path} → ${item.destination_path})`
|
||||
)
|
||||
)
|
||||
),
|
||||
// Only evidence-safe work is offered. Ambiguous operations get no button at
|
||||
// all — an action the server would refuse must not look available.
|
||||
recoverable.length
|
||||
? el(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "resolve-recovery",
|
||||
onclick: () => run(() => api.resolveRecovery(), "Resolving…"),
|
||||
},
|
||||
`Resolve ${recoverable.length} recoverable rename(s) from evidence`
|
||||
)
|
||||
: el(
|
||||
"p",
|
||||
{ "data-testid": "manual-only" },
|
||||
"No automatic action is safe here: source and destination both exist, or neither " +
|
||||
"does. Inspect the paths above and resolve them by hand."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -327,15 +327,11 @@ export async function renderStats(root) {
|
||||
// Approving never renames anything — it records the approved name only.
|
||||
export async function renderAlbums(root, params = {}) {
|
||||
setActiveNav("albums");
|
||||
let evidence, proposals, recovery;
|
||||
let evidence, proposals;
|
||||
try {
|
||||
[evidence, proposals, recovery] = await Promise.all([
|
||||
[evidence, proposals] = await Promise.all([
|
||||
api.albumEvidence({ limit: 500 }),
|
||||
api.albumProposals(),
|
||||
// An unresolved rename means folder paths on disk may disagree with the
|
||||
// database, so the server refuses name changes until it is resolved. Ask up
|
||||
// front rather than offering a button that is guaranteed to fail.
|
||||
api.renameRecovery(),
|
||||
]);
|
||||
} catch (error) {
|
||||
root.replaceChildren(errorBanner(`Failed to load albums: ${error.message}`));
|
||||
@@ -374,27 +370,17 @@ export async function renderAlbums(root, params = {}) {
|
||||
})
|
||||
);
|
||||
|
||||
const renameBlocked = recovery.blocks_mutation;
|
||||
const detail = selected
|
||||
? albumDetail(
|
||||
root,
|
||||
folders.find((folder) => folder.album === selected),
|
||||
byAlbum.get(selected),
|
||||
params,
|
||||
renameBlocked
|
||||
params
|
||||
)
|
||||
: el("p", { class: "muted" }, "No albums with evidence yet.");
|
||||
|
||||
root.replaceChildren(
|
||||
el("h1", {}, "Albums"),
|
||||
renameBlocked
|
||||
? el(
|
||||
"div",
|
||||
{ class: "alert", role: "alert", "data-testid": "rename-blocked" },
|
||||
"An interrupted rename is unresolved. Name changes are paused until it is " +
|
||||
"resolved on the Renames page; browsing stays available."
|
||||
)
|
||||
: null,
|
||||
el(
|
||||
"div",
|
||||
{ class: "toolbar" },
|
||||
@@ -403,8 +389,6 @@ export async function renderAlbums(root, params = {}) {
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "generate-proposals",
|
||||
disabled: renameBlocked ? "disabled" : false,
|
||||
title: renameBlocked ? "an unresolved rename must be resolved first" : false,
|
||||
onclick: async () => {
|
||||
try {
|
||||
await api.generateProposals({});
|
||||
@@ -421,7 +405,7 @@ export async function renderAlbums(root, params = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function albumDetail(root, folder, proposal, params, renameBlocked = false) {
|
||||
function albumDetail(root, folder, proposal, params) {
|
||||
if (!folder) return el("p", { class: "muted" }, "Album not found.");
|
||||
|
||||
const evidenceBlock = el(
|
||||
@@ -489,13 +473,8 @@ function albumDetail(root, folder, proposal, params, renameBlocked = false) {
|
||||
{
|
||||
class: "primary",
|
||||
"data-testid": "approve",
|
||||
disabled:
|
||||
renameBlocked || proposal.stale || proposal.status === "error" ? "disabled" : false,
|
||||
title: renameBlocked
|
||||
? "an unresolved rename must be resolved first"
|
||||
: proposal.stale
|
||||
? "evidence changed — regenerate first"
|
||||
: false,
|
||||
disabled: proposal.stale || proposal.status === "error" ? "disabled" : false,
|
||||
title: proposal.stale ? "evidence changed — regenerate first" : false,
|
||||
onclick: () => mutate(root, () => api.approveProposal(proposal.album, { expected_version: proposal.version }), params),
|
||||
},
|
||||
proposal.status === "approved" ? "Approved" : "Approve name"
|
||||
|
||||
@@ -25,7 +25,6 @@ from photo_pipeline.api.routes import (
|
||||
renames,
|
||||
safety,
|
||||
thumbnails,
|
||||
uploads,
|
||||
workflow,
|
||||
)
|
||||
|
||||
@@ -68,7 +67,6 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
app.include_router(library.router, prefix="/api/v1")
|
||||
app.include_router(albums.router, prefix="/api/v1")
|
||||
app.include_router(renames.router, prefix="/api/v1")
|
||||
app.include_router(uploads.router, prefix="/api/v1")
|
||||
# Static single-page app (hash-routed). Mounted last so /api/v1 wins.
|
||||
if FRONTEND_DIR.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="app")
|
||||
|
||||
@@ -17,7 +17,6 @@ from photo_pipeline.schemas import (
|
||||
)
|
||||
from photo_pipeline.services.albums import AlbumService
|
||||
from photo_pipeline.services.proposals import ConflictError, ProposalError, ProposalService
|
||||
from photo_pipeline.services.rename_journal import RenameJournal
|
||||
|
||||
router = APIRouter(tags=["albums"])
|
||||
|
||||
@@ -38,22 +37,6 @@ def _error(status: int, code: str, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
|
||||
|
||||
|
||||
def _unresolved_rename(request: Request) -> JSONResponse | None:
|
||||
"""Refuse work that feeds renaming while the library may be half-renamed.
|
||||
|
||||
An unresolved rename means some folder paths on disk disagree with the database,
|
||||
so evidence gathered now — and any approval based on it — could describe a
|
||||
location that no longer exists. Reading stays available; only mutation stops.
|
||||
"""
|
||||
if RenameJournal(request.app.state.session_factory).blocks_mutation():
|
||||
return _error(
|
||||
409,
|
||||
"rename_recovery_required",
|
||||
"an unresolved rename is in progress; resolve it before changing album names",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/albums/evidence")
|
||||
def evidence(
|
||||
request: Request,
|
||||
@@ -64,8 +47,8 @@ def evidence(
|
||||
|
||||
|
||||
@router.post("/albums/proposals")
|
||||
def generate(body: GenerateProposalsRequest, request: Request):
|
||||
return _unresolved_rename(request) or _proposals(request).generate(body.albums)
|
||||
def generate(body: GenerateProposalsRequest, request: Request) -> dict:
|
||||
return _proposals(request).generate(body.albums)
|
||||
|
||||
|
||||
@router.get("/albums/proposals")
|
||||
@@ -93,9 +76,6 @@ def edit_proposal(album: str, body: EditProposalRequest, request: Request):
|
||||
|
||||
@router.post("/albums/proposals/{album:path}/approve")
|
||||
def approve_proposal(album: str, body: ApproveProposalRequest, request: Request):
|
||||
blocked = _unresolved_rename(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
try:
|
||||
return _proposals(request).approve(album, expected_version=body.expected_version)
|
||||
except ConflictError as error:
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Upload preflight API (US05-01).
|
||||
|
||||
Preflight is a command, not a resource read: it contacts the Immich server, hashes
|
||||
the current bytes, and issues a token. It uploads nothing — starting a batch is
|
||||
US05-02, so there is deliberately no start endpoint here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from photo_pipeline.services.uploads import UploadError, UploadService
|
||||
|
||||
router = APIRouter(tags=["uploads"])
|
||||
|
||||
|
||||
class PreflightRequest(BaseModel):
|
||||
# ``None`` means every album; an explicit list scopes the check.
|
||||
albums: list[str] | None = None
|
||||
# Approving a partial upload is an explicit act, never a default.
|
||||
allow_partial: bool = False
|
||||
|
||||
|
||||
def _service(request: Request) -> UploadService:
|
||||
return UploadService(request.app.state.session_factory, config=request.app.state.config)
|
||||
|
||||
|
||||
@router.post("/upload-preflight")
|
||||
def preflight(request: Request, body: PreflightRequest | None = None):
|
||||
body = body or PreflightRequest()
|
||||
try:
|
||||
return _service(request).preflight(body.albums, allow_partial=body.allow_partial)
|
||||
except UploadError as error:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"error": {"code": "unknown_album", "message": str(error)}},
|
||||
)
|
||||
@@ -37,8 +37,6 @@ class Config(BaseModel):
|
||||
|
||||
vision_api_key: SecretStr | None = None
|
||||
immich_api_key: SecretStr | None = None
|
||||
immich_server_url: str = ""
|
||||
immich_go_binary: str = "immich-go"
|
||||
|
||||
@property
|
||||
def database_path(self) -> Path:
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""immich-go adapter: binary discovery, version, and redacted command preview.
|
||||
|
||||
Upload is the only stage that needs credentials, so this module is also the single
|
||||
place that knows an API key exists. It never returns, logs, or renders the secret:
|
||||
:func:`build_command` produces the real argument list for the uploader, and
|
||||
:func:`redact` produces the copy that is safe for the API, the browser, and the
|
||||
activity log. Both come from the same builder so the preview can never drift from
|
||||
the command that would actually run.
|
||||
|
||||
Server reachability uses ``/api/server/ping`` through stdlib ``urllib`` — the app
|
||||
has no HTTP client dependency and this is one request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REDACTED = "***"
|
||||
PING_PATH = "/api/server/ping"
|
||||
PING_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def find_binary(binary: str = "immich-go") -> str | None:
|
||||
"""Absolute path of the uploader, or ``None`` when it is not installed."""
|
||||
return shutil.which(binary)
|
||||
|
||||
|
||||
def version(binary: str = "immich-go") -> str | None:
|
||||
"""Reported uploader version, or ``None`` when it is missing or unusable.
|
||||
|
||||
The version is persisted with every batch (concept §8) because immich-go's
|
||||
flags and report text change between releases.
|
||||
"""
|
||||
path = find_binary(binary)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
output = (result.stdout or result.stderr or "").strip()
|
||||
return output.splitlines()[0].strip() if output else None
|
||||
|
||||
|
||||
def ping(server_url: str, *, timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str | None]:
|
||||
"""``(reachable, detail)`` for the configured Immich server.
|
||||
|
||||
A reachable Immich answers ``{"res": "pong"}``. Anything else — wrong host, no
|
||||
Immich, HTTP error — is a blocker with a short human detail. The detail never
|
||||
carries the URL's credentials because the key travels in a header, not the URL.
|
||||
"""
|
||||
if not server_url:
|
||||
return False, "no server URL configured"
|
||||
url = server_url.rstrip("/") + PING_PATH
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310
|
||||
payload = json.loads(response.read().decode("utf-8") or "{}")
|
||||
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as error:
|
||||
return False, f"{type(error).__name__}: {error}"
|
||||
if payload.get("res") == "pong":
|
||||
return True, None
|
||||
return False, "server did not answer with pong"
|
||||
|
||||
|
||||
def build_command(
|
||||
*,
|
||||
binary: str,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
album_name: str,
|
||||
folder: Path | str,
|
||||
) -> list[str]:
|
||||
"""The exact upload invocation for one folder-as-album batch."""
|
||||
return [
|
||||
binary,
|
||||
"upload",
|
||||
"from-folder",
|
||||
f"--server={server_url}",
|
||||
f"--api-key={api_key}",
|
||||
f"--album-name={album_name}",
|
||||
str(folder),
|
||||
]
|
||||
|
||||
|
||||
def redact(command: list[str]) -> list[str]:
|
||||
"""The same command with every secret-bearing argument masked."""
|
||||
return [f"--api-key={REDACTED}" if arg.startswith("--api-key=") else arg for arg in command]
|
||||
|
||||
|
||||
def preview_command(
|
||||
*, binary: str, server_url: str, album_name: str, folder: Path | str
|
||||
) -> list[str]:
|
||||
"""Redacted preview built without ever handling the real key."""
|
||||
return redact(
|
||||
build_command(
|
||||
binary=binary,
|
||||
server_url=server_url,
|
||||
api_key=REDACTED,
|
||||
album_name=album_name,
|
||||
folder=folder,
|
||||
)
|
||||
)
|
||||
@@ -229,12 +229,6 @@ class RenameJournal:
|
||||
classification, reason = _classify(row["journal_state"], source_exists, destination_exists)
|
||||
return {
|
||||
"operation_id": operation_id,
|
||||
"plan_id": row["plan_id"],
|
||||
"album": row["album"],
|
||||
# The paths travel with the verdict so a reviewer can see which folder is
|
||||
# unresolved without correlating an opaque operation id by hand.
|
||||
"source_path": row["source_path"],
|
||||
"destination_path": row["destination_path"],
|
||||
"journal_state": row["journal_state"],
|
||||
"classification": classification,
|
||||
"reason": reason,
|
||||
|
||||
@@ -418,17 +418,8 @@ def _plan_dict(plan: RenamePlan, operations: list[RenameOperation]) -> dict:
|
||||
"asset_count": operation.asset_count,
|
||||
"same_filesystem": operation.same_filesystem,
|
||||
"case_only": operation.case_only,
|
||||
# Severity is decided here, not in the browser: a client that has to
|
||||
# keep its own copy of BLOCKING_CODES will eventually disagree with
|
||||
# the validator about whether a plan can run.
|
||||
"issues": [
|
||||
{**issue, "blocking": issue["code"] in BLOCKING_CODES}
|
||||
for issue in json.loads(operation.issues or "[]")
|
||||
],
|
||||
"issues": json.loads(operation.issues or "[]"),
|
||||
"journal_state": operation.journal_state,
|
||||
"verified_at": operation.verified_at.isoformat() if operation.verified_at else None,
|
||||
"error_code": operation.error_code,
|
||||
"error_message": operation.error_message,
|
||||
}
|
||||
for operation in operations
|
||||
],
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
"""UploadService — preflight for Immich upload (US05-01).
|
||||
|
||||
Upload is the first stage that sends the library somewhere the app cannot take it
|
||||
back from, so nothing here uploads: preflight only *proves* a scope is safe and
|
||||
issues a token that a later start command must present (US05-02).
|
||||
|
||||
What it proves (concept §8 "preflight"):
|
||||
|
||||
- credentials are configured and the Immich server answers, without ever putting
|
||||
the API key in a response, a preview, or a log line;
|
||||
- ``immich-go`` is installed and its version is recorded;
|
||||
- no rename is half-applied — the journal must not block library mutation;
|
||||
- every asset in scope is canonical, present, and decided ``sfw``/``nsfw`` with a
|
||||
verified safety EXIF checkpoint;
|
||||
- every SFW asset also has a completed analysis EXIF checkpoint;
|
||||
- the bytes on disk right now still hash to what inventory recorded, so the album
|
||||
preview describes the exact bytes that would be uploaded.
|
||||
|
||||
Blocker codes are structured, never prose the UI has to parse:
|
||||
|
||||
``no_library_root``, ``credentials_missing``, ``server_unreachable``,
|
||||
``immich_go_missing``, ``rename_pending``, ``unknown_album``, ``empty_scope``,
|
||||
``file_missing``, ``bytes_changed``, ``safety_undecided``, ``safety_deferred``,
|
||||
``safety_exif_unverified``, ``analysis_incomplete``, ``partial_scope``.
|
||||
|
||||
**Partial scope is a blocker, not a default.** An album with any blocked asset is
|
||||
refused unless the caller passes the explicit ``allow_partial`` policy, which is
|
||||
itself part of the token — a token issued for a partial upload can never be
|
||||
replayed as a full one.
|
||||
|
||||
The token is derived, not stored: it is a digest of the whole report (minus its
|
||||
timestamp), so any change that matters — a new decision, edited bytes, a different
|
||||
server, a resolved blocker, a different policy — produces a different token and the
|
||||
old one is stale by construction. No table, no invalidation bookkeeping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.integrations import immich_go
|
||||
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||
from photo_pipeline.services.albums import album_label
|
||||
from photo_pipeline.services.hashing import sha256_file
|
||||
from photo_pipeline.services.rename_journal import RenameJournal
|
||||
|
||||
PREFLIGHT_VERSION = 1
|
||||
TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}"
|
||||
SFW = "sfw"
|
||||
NSFW = "nsfw"
|
||||
ANALYZED = "analyzed"
|
||||
|
||||
|
||||
class UploadError(RuntimeError):
|
||||
"""Invalid preflight request (unknown album in the requested scope)."""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _issue(code: str, message: str) -> dict:
|
||||
return {"code": code, "message": message}
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._config = config
|
||||
self._roots = tuple(Path(root) for root in config.library_roots)
|
||||
|
||||
# ── preflight ─────────────────────────────────────────────────────────────
|
||||
|
||||
def preflight(self, albums: list[str] | None = None, *, allow_partial: bool = False) -> dict:
|
||||
"""Validate an upload scope and issue its token. Read-only, no upload."""
|
||||
report = {
|
||||
"schema_version": PREFLIGHT_VERSION,
|
||||
"policy": {"allow_partial": allow_partial},
|
||||
"blockers": [],
|
||||
"credentials": self._credentials(),
|
||||
"server": self._server(),
|
||||
"uploader": self._uploader(),
|
||||
}
|
||||
report["blockers"] += self._environment_blockers(report)
|
||||
report["albums"] = self._albums(albums, allow_partial=allow_partial)
|
||||
report["totals"] = _totals(report["albums"])
|
||||
if not report["albums"]:
|
||||
report["blockers"].append(
|
||||
_issue("empty_scope", "no canonical, active assets are in the selected scope")
|
||||
)
|
||||
report["state"] = (
|
||||
"ready"
|
||||
if not report["blockers"] and all(a["state"] == "ready" for a in report["albums"])
|
||||
else "blocked"
|
||||
)
|
||||
report["token"] = _token(report)
|
||||
report["generated_at"] = _now().isoformat()
|
||||
return report
|
||||
|
||||
def verify_token(
|
||||
self, token: str, albums: list[str] | None = None, *, allow_partial: bool = False
|
||||
) -> bool:
|
||||
"""True when ``token`` still describes the current state of that scope.
|
||||
|
||||
Recomputed rather than looked up, so an externally edited file or a changed
|
||||
decision invalidates it even though nothing wrote to the database.
|
||||
"""
|
||||
return bool(token) and token == self.preflight(albums, allow_partial=allow_partial)["token"]
|
||||
|
||||
# ── environment ───────────────────────────────────────────────────────────
|
||||
|
||||
def _credentials(self) -> dict:
|
||||
"""Presence only — the key itself never leaves configuration."""
|
||||
return {
|
||||
"server_url": self._config.immich_server_url,
|
||||
"api_key_configured": self._config.immich_api_key is not None,
|
||||
}
|
||||
|
||||
def _server(self) -> dict:
|
||||
reachable, detail = immich_go.ping(self._config.immich_server_url)
|
||||
return {"reachable": reachable, "detail": detail}
|
||||
|
||||
def _uploader(self) -> dict:
|
||||
binary = self._config.immich_go_binary
|
||||
return {
|
||||
"binary": binary,
|
||||
"installed": immich_go.find_binary(binary) is not None,
|
||||
"version": immich_go.version(binary),
|
||||
}
|
||||
|
||||
def _environment_blockers(self, report: dict) -> list[dict]:
|
||||
blockers: list[dict] = []
|
||||
if not self._roots:
|
||||
blockers.append(_issue("no_library_root", "no library root is configured"))
|
||||
if not report["credentials"]["api_key_configured"] or not self._config.immich_server_url:
|
||||
blockers.append(
|
||||
_issue("credentials_missing", "an Immich server URL and API key are required")
|
||||
)
|
||||
elif not report["server"]["reachable"]:
|
||||
blockers.append(
|
||||
_issue(
|
||||
"server_unreachable", f"Immich did not respond: {report['server']['detail']}"
|
||||
)
|
||||
)
|
||||
if not report["uploader"]["installed"]:
|
||||
blockers.append(
|
||||
_issue("immich_go_missing", f"{self._config.immich_go_binary} is not installed")
|
||||
)
|
||||
if RenameJournal(self._session_factory).blocks_mutation():
|
||||
blockers.append(
|
||||
_issue("rename_pending", "an unresolved rename must be recovered before upload")
|
||||
)
|
||||
return blockers
|
||||
|
||||
# ── scope ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _albums(self, requested: list[str] | None, *, allow_partial: bool) -> list[dict]:
|
||||
by_album = self._scope()
|
||||
if requested is not None:
|
||||
unknown = sorted(set(requested) - set(by_album))
|
||||
if unknown:
|
||||
raise UploadError(f"unknown album(s): {', '.join(unknown)}")
|
||||
by_album = {name: by_album[name] for name in sorted(set(requested))}
|
||||
return [
|
||||
self._album(name, rows, allow_partial=allow_partial)
|
||||
for name, rows in sorted(by_album.items())
|
||||
]
|
||||
|
||||
def _scope(self) -> dict[str, list[dict]]:
|
||||
"""Canonical, active assets grouped by album, each with its stage evidence."""
|
||||
with self._session_factory() as session:
|
||||
assets = list(
|
||||
session.scalars(
|
||||
select(Asset).where(
|
||||
Asset.canonical_asset_id.is_(None),
|
||||
Asset.availability_state == "active",
|
||||
Asset.current_path.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
reviews: dict[str, SafetyReview] = {}
|
||||
for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)):
|
||||
reviews[review.asset_id] = review # latest row per asset wins
|
||||
analyses = {
|
||||
result.asset_id: result for result in session.scalars(select(AnalysisResult))
|
||||
}
|
||||
|
||||
by_album: dict[str, list[dict]] = {}
|
||||
for asset in assets:
|
||||
by_album.setdefault(album_label(asset.current_path, self._roots), []).append(
|
||||
{
|
||||
"asset_id": asset.id,
|
||||
"path": asset.current_path,
|
||||
"expected_sha256": asset.current_sha256,
|
||||
"review": reviews.get(asset.id),
|
||||
"analysis": analyses.get(asset.id),
|
||||
}
|
||||
)
|
||||
return by_album
|
||||
|
||||
def _album(self, name: str, rows: list[dict], *, allow_partial: bool) -> dict:
|
||||
folder = Path(rows[0]["path"]).parent
|
||||
items = sorted((self._item(row) for row in rows), key=lambda item: item["current_path"])
|
||||
blocked = [item for item in items if item["blockers"]]
|
||||
eligible = [item for item in items if not item["blockers"]]
|
||||
blockers: list[dict] = []
|
||||
if blocked and not allow_partial:
|
||||
blockers.append(
|
||||
_issue(
|
||||
"partial_scope",
|
||||
f"{len(blocked)} of {len(items)} asset(s) are not upload-ready; resolve them "
|
||||
"or approve a partial upload explicitly",
|
||||
)
|
||||
)
|
||||
if not eligible:
|
||||
blockers.append(_issue("empty_scope", "no upload-ready asset remains in this album"))
|
||||
# Folder-as-album: Immich names the album after the leaf folder, so the
|
||||
# preview shows exactly what the server will create.
|
||||
album_name = folder.name
|
||||
return {
|
||||
"album": name,
|
||||
"folder": str(folder),
|
||||
"album_name": album_name,
|
||||
"asset_count": len(items),
|
||||
"eligible_count": len(eligible),
|
||||
"blocked_count": len(blocked),
|
||||
"partial": bool(blocked),
|
||||
"state": "blocked" if blockers else "ready",
|
||||
"blockers": blockers,
|
||||
"assets": items,
|
||||
"command_preview": immich_go.preview_command(
|
||||
binary=self._config.immich_go_binary,
|
||||
server_url=self._config.immich_server_url,
|
||||
album_name=album_name,
|
||||
folder=folder,
|
||||
),
|
||||
}
|
||||
|
||||
def _item(self, row: dict) -> dict:
|
||||
"""One asset's readiness, including its hash *as it is on disk right now*."""
|
||||
path = Path(row["path"])
|
||||
review: SafetyReview | None = row["review"]
|
||||
analysis: AnalysisResult | None = row["analysis"]
|
||||
decision = review.decision if review else None
|
||||
blockers: list[dict] = []
|
||||
current_sha256 = None
|
||||
|
||||
if not path.exists():
|
||||
blockers.append(_issue("file_missing", f"{path} is missing"))
|
||||
else:
|
||||
# ponytail: full re-hash every preflight. Gate on (size, mtime_ns) first
|
||||
# if a large library makes this slow — the hash stays authoritative.
|
||||
current_sha256 = sha256_file(path)
|
||||
if row["expected_sha256"] and current_sha256 != row["expected_sha256"]:
|
||||
blockers.append(
|
||||
_issue("bytes_changed", f"{path} changed since its last verified checkpoint")
|
||||
)
|
||||
|
||||
if decision not in (SFW, NSFW):
|
||||
code = "safety_deferred" if decision == "deferred" else "safety_undecided"
|
||||
blockers.append(_issue(code, "a confirmed sfw/nsfw safety decision is required"))
|
||||
elif review.exif_verified_at is None:
|
||||
blockers.append(
|
||||
_issue("safety_exif_unverified", "the safety EXIF checkpoint is not verified")
|
||||
)
|
||||
elif decision == SFW and not (
|
||||
analysis and analysis.status == ANALYZED and analysis.exif_written_at is not None
|
||||
):
|
||||
blockers.append(
|
||||
_issue("analysis_incomplete", "SFW assets need a verified analysis EXIF checkpoint")
|
||||
)
|
||||
|
||||
return {
|
||||
"asset_id": row["asset_id"],
|
||||
"current_path": str(path),
|
||||
"safety_decision": decision,
|
||||
"current_sha256": current_sha256,
|
||||
"blockers": blockers,
|
||||
}
|
||||
|
||||
|
||||
def _totals(albums: list[dict]) -> dict:
|
||||
return {
|
||||
"albums": len(albums),
|
||||
"ready_albums": sum(1 for album in albums if album["state"] == "ready"),
|
||||
"assets": sum(album["asset_count"] for album in albums),
|
||||
"eligible": sum(album["eligible_count"] for album in albums),
|
||||
"blocked": sum(album["blocked_count"] for album in albums),
|
||||
}
|
||||
|
||||
|
||||
def _token(report: dict) -> str:
|
||||
"""Digest of everything the report asserts. Volatile fields are excluded so the
|
||||
same state always yields the same token; every relevant change breaks it."""
|
||||
payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")}
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"{TOKEN_PREFIX}:{digest}"
|
||||
@@ -30,5 +30,4 @@ testpaths = ["tests"]
|
||||
markers = [
|
||||
"phase_b: Phase B end-to-end acceptance (US02-07) — API, worker-recovery, and browser journeys",
|
||||
"phase_c: Phase C end-to-end acceptance (US03-05) — album proposal API and browser journeys",
|
||||
"phase_d: Phase D end-to-end acceptance (US04-06) — guarded rename API, fault, and browser journeys",
|
||||
]
|
||||
|
||||
@@ -14,9 +14,7 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -161,88 +159,3 @@ def wait_until(predicate, *, timeout: float = 20, interval: float = 0.1):
|
||||
return value
|
||||
time.sleep(interval)
|
||||
raise AssertionError("condition not met before timeout")
|
||||
|
||||
|
||||
# ── Phase D: an analysed album, ready to be named and renamed ────────────────
|
||||
|
||||
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class session_factory:
|
||||
"""Session factory against a seeded database, for the few things a test has to
|
||||
set up or inspect below the API — journal states, mainly."""
|
||||
|
||||
def __init__(self, seeded: Seeded) -> None:
|
||||
self._seeded = seeded
|
||||
|
||||
def __enter__(self):
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||
|
||||
config = Config.from_env(
|
||||
{
|
||||
"PHOTO_PIPELINE_DATA_DIR": str(self._seeded.data),
|
||||
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(self._seeded.lib),
|
||||
}
|
||||
)
|
||||
run_migrations(config.database_url)
|
||||
self._engine = create_db_engine(config.database_url)
|
||||
return create_session_factory(self._engine)
|
||||
|
||||
def __exit__(self, *_):
|
||||
self._engine.dispose()
|
||||
return False
|
||||
|
||||
|
||||
def seed_album(tmp_path: Path, album: str = "rome", names: tuple[str, ...] = ("a.jpg", "b.jpg")):
|
||||
"""A library holding one album folder whose photos are confirmed SFW and analysed
|
||||
— the state a naming proposal, and therefore a rename plan, is built from."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||
from photo_pipeline.services.inventory import InventoryService
|
||||
|
||||
seeded = seed_library(tmp_path, {}, {})
|
||||
folder = seeded.lib / album
|
||||
folder.mkdir(parents=True)
|
||||
for index, name in enumerate(names):
|
||||
image(folder / name, index + 1)
|
||||
|
||||
with session_factory(seeded) as sf:
|
||||
InventoryService(sf).scan(seeded.lib)
|
||||
with sf() as session:
|
||||
rows = list(session.execute(select(Asset.id, Asset.current_path)).all())
|
||||
for asset_id, path in rows:
|
||||
session.add(
|
||||
SafetyReview(
|
||||
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
AnalysisResult(
|
||||
asset_id=asset_id,
|
||||
status="analyzed",
|
||||
description=f"a view of {path}",
|
||||
tags='["ruins", "city"]',
|
||||
approx_year=2019,
|
||||
location_hint="Rome",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
seeded.asset_ids.update({Path(path).stem: aid for aid, path in rows})
|
||||
return seeded
|
||||
|
||||
|
||||
def approve_album(base: str, *, album: str = "rome", name: str) -> None:
|
||||
"""Generate a proposal, set its final name, and approve it over HTTP."""
|
||||
httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=10).raise_for_status()
|
||||
for payload, route in (
|
||||
({"name": name}, "edit"),
|
||||
({}, "approve"),
|
||||
):
|
||||
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=10).json()
|
||||
httpx.post(
|
||||
f"{base}/api/v1/albums/proposals/{album}/{route}",
|
||||
json={**payload, "expected_version": current["version"]},
|
||||
timeout=10,
|
||||
).raise_for_status()
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
"""Phase D end-to-end acceptance (US04-06): guarded renaming, black box.
|
||||
|
||||
Every journey here drives a real ``photo_pipeline serve`` child process over HTTP —
|
||||
plan, export, confirm, apply, collide, go stale, crash, recover, roll back. The
|
||||
crashes are real: the server is killed by the ``PHOTO_PIPELINE_FAULT_AFTER`` barrier
|
||||
at each persisted journal transition in turn, then a fresh process is started against
|
||||
the same database and library and has to reconcile the wreckage from evidence alone.
|
||||
|
||||
Photos really move. After every journey the assertions read the filesystem and the
|
||||
inventory back: the asset set, the stable IDs, and the content hashes must be exactly
|
||||
what they were before, only at new paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory
|
||||
|
||||
pytestmark = pytest.mark.phase_d
|
||||
|
||||
TIMEOUT = 10
|
||||
APPROVED = "2019 Rome"
|
||||
CRASH_POINTS = ["moving", "moved", "database_updated", "verified"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path):
|
||||
seeded = seed_album(tmp_path)
|
||||
running = Server(seeded).start()
|
||||
running.seeded = seeded
|
||||
try:
|
||||
yield running
|
||||
finally:
|
||||
running.stop()
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _plan(base) -> dict:
|
||||
response = httpx.post(f"{base}/api/v1/rename-plans", timeout=TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _get_plan(base, plan_id) -> dict:
|
||||
return httpx.get(f"{base}/api/v1/rename-plans/{plan_id}", timeout=TIMEOUT).json()
|
||||
|
||||
|
||||
def _apply(base, plan, **body):
|
||||
payload = {"expected_version": plan["version"], **body}
|
||||
return httpx.post(
|
||||
f"{base}/api/v1/rename-plans/{plan['id']}/apply", json=payload, timeout=TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def _inventory(base) -> dict[str, dict]:
|
||||
"""Every asset by stable ID, so identity can be compared across a rename."""
|
||||
items = httpx.get(
|
||||
f"{base}/api/v1/inventory/assets", params={"limit": 200}, timeout=TIMEOUT
|
||||
).json()["items"]
|
||||
return {item["id"]: item for item in items}
|
||||
|
||||
|
||||
def _content(root) -> dict[str, bytes]:
|
||||
return {
|
||||
str(path.relative_to(root)): path.read_bytes()
|
||||
for path in sorted(root.rglob("*"))
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
def _recovery(base) -> dict:
|
||||
return httpx.get(f"{base}/api/v1/rename-recovery", timeout=TIMEOUT).json()
|
||||
|
||||
|
||||
def _journal_states(seeded, plan_id) -> list[str]:
|
||||
from photo_pipeline.services.rename_journal import RenameJournal
|
||||
|
||||
with session_factory(seeded) as sf:
|
||||
return [row["journal_state"] for row in RenameJournal(sf).operations(plan_id)]
|
||||
|
||||
|
||||
# ── US04-01: plan and export ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_plan_and_export_describe_every_move_without_touching_the_library(server):
|
||||
before = _content(server.seeded.lib)
|
||||
approve_album(server.base, name=APPROVED)
|
||||
plan = _plan(server.base)
|
||||
|
||||
assert plan["state"] == "validated" and plan["operation_count"] == 1
|
||||
operation = plan["operations"][0]
|
||||
assert operation["source_path"].endswith("/rome")
|
||||
assert operation["destination_path"].endswith(f"/{APPROVED}")
|
||||
assert operation["asset_count"] == 2
|
||||
assert set(operation["asset_ids"]) == set(_inventory(server.base))
|
||||
|
||||
export = httpx.get(
|
||||
f"{server.base}/api/v1/rename-plans/{plan['id']}/export", timeout=TIMEOUT
|
||||
).json()
|
||||
assert export["schema_version"] == 1
|
||||
assert export["checksum"] == plan["checksum"]
|
||||
assert [op["source_path"] for op in export["operations"]] == [operation["source_path"]]
|
||||
# Portable evidence must not smuggle out anything sensitive.
|
||||
assert "token" not in str(export).lower() and "key" not in str(export).lower()
|
||||
|
||||
# Planning is a preview: not one byte moved.
|
||||
assert _content(server.seeded.lib) == before
|
||||
|
||||
|
||||
# ── US04-03: confirmation and apply ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_requires_the_current_confirmation_token(server):
|
||||
approve_album(server.base, name=APPROVED)
|
||||
plan = _plan(server.base)
|
||||
|
||||
stale = _apply(server.base, {**plan, "version": plan["version"] + 7})
|
||||
assert stale.status_code == 409 and stale.json()["error"]["code"] == "version_conflict"
|
||||
|
||||
wrong_checksum = _apply(server.base, plan, expected_checksum="0" * 64)
|
||||
assert wrong_checksum.status_code == 409
|
||||
|
||||
assert (server.seeded.lib / "rome").is_dir(), "a refused confirmation moves nothing"
|
||||
|
||||
|
||||
def test_a_valid_apply_preserves_ids_hashes_and_the_asset_set(server):
|
||||
approve_album(server.base, name=APPROVED)
|
||||
before = _inventory(server.base)
|
||||
before_content = _content(server.seeded.lib)
|
||||
plan = _plan(server.base)
|
||||
|
||||
applied = _apply(server.base, plan, expected_checksum=plan["checksum"]).json()
|
||||
assert applied["applied"] == 1 and applied["failed"] == 0 and applied["state"] == "applied"
|
||||
|
||||
after = _inventory(server.base)
|
||||
assert set(after) == set(before), "renaming must not change asset identity"
|
||||
assert {item["current_sha256"] for item in after.values()} == {
|
||||
item["current_sha256"] for item in before.values()
|
||||
}
|
||||
assert all(APPROVED in item["current_path"] for item in after.values())
|
||||
# Same bytes, new folder — nothing was rewritten in the move.
|
||||
assert _content(server.seeded.lib) == {
|
||||
key.replace("rome/", f"{APPROVED}/"): value for key, value in before_content.items()
|
||||
}
|
||||
assert _journal_states(server.seeded, plan["id"]) == ["complete"]
|
||||
|
||||
|
||||
def test_a_case_only_rename_applies_on_a_case_insensitive_filesystem(tmp_path):
|
||||
seeded = seed_album(tmp_path, album="rome")
|
||||
running = Server(seeded).start()
|
||||
try:
|
||||
approve_album(running.base, name="Rome")
|
||||
plan = _plan(running.base)
|
||||
assert plan["operations"][0]["case_only"] is True
|
||||
|
||||
assert _apply(running.base, plan).json()["applied"] == 1
|
||||
entries = {path.name for path in seeded.lib.iterdir()}
|
||||
assert "Rome" in entries
|
||||
# The staged intermediate name must not survive the procedure.
|
||||
assert not any(name.startswith(".rename-") for name in entries)
|
||||
assert all("/Rome/" in item["current_path"] for item in _inventory(running.base).values())
|
||||
finally:
|
||||
running.stop()
|
||||
|
||||
|
||||
def test_a_collision_is_refused_and_the_occupant_survives(server):
|
||||
approve_album(server.base, name=APPROVED)
|
||||
occupied = server.seeded.lib / APPROVED
|
||||
occupied.mkdir()
|
||||
(occupied / "precious.jpg").write_bytes(b"do not lose me")
|
||||
|
||||
plan = _plan(server.base)
|
||||
assert plan["state"] == "invalid" and "destination_exists" in plan["blockers"]
|
||||
|
||||
refused = _apply(server.base, plan)
|
||||
assert refused.status_code == 422 and refused.json()["error"]["code"] == "cannot_apply"
|
||||
assert (occupied / "precious.jpg").read_bytes() == b"do not lose me"
|
||||
assert (server.seeded.lib / "rome").is_dir()
|
||||
|
||||
|
||||
def test_a_source_that_changed_after_planning_is_refused(server):
|
||||
approve_album(server.base, name=APPROVED)
|
||||
plan = _plan(server.base)
|
||||
# The plan recorded per-asset hashes; the file changes before confirmation.
|
||||
(server.seeded.lib / "rome" / "a.jpg").write_bytes(b"tampered")
|
||||
|
||||
result = _apply(server.base, plan).json()
|
||||
assert result["failed"] == 1 and result["applied"] == 0
|
||||
assert (server.seeded.lib / "rome").is_dir(), "a failed precondition leaves the source alone"
|
||||
operation = _get_plan(server.base, plan["id"])["operations"][0]
|
||||
assert operation["journal_state"] == "failed"
|
||||
assert operation["error_code"] == "source_changed"
|
||||
|
||||
|
||||
# ── US04-04: crash points, recovery, rollback ────────────────────────────────
|
||||
|
||||
|
||||
def _crash_during_apply(seeded, plan, state):
|
||||
"""Apply with the fault barrier armed: the server dies at ``state``, mid-move."""
|
||||
crashing = Server(seeded, extra_env={"PHOTO_PIPELINE_FAULT_AFTER": state}).start()
|
||||
try:
|
||||
with pytest.raises(httpx.HTTPError):
|
||||
_apply(crashing.base, plan)
|
||||
finally:
|
||||
crashing.stop()
|
||||
assert crashing.proc is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("crash_point", CRASH_POINTS)
|
||||
def test_every_journal_crash_point_recovers_without_losing_content(tmp_path, crash_point):
|
||||
seeded = seed_album(tmp_path)
|
||||
first = Server(seeded).start()
|
||||
try:
|
||||
approve_album(first.base, name=APPROVED)
|
||||
before = _inventory(first.base)
|
||||
before_bytes = sorted(_content(seeded.lib).values())
|
||||
plan = _plan(first.base)
|
||||
finally:
|
||||
first.stop()
|
||||
|
||||
_crash_during_apply(seeded, plan, crash_point)
|
||||
|
||||
# A brand-new process, no in-memory state: everything comes from the journal.
|
||||
restarted = Server(seeded).start()
|
||||
try:
|
||||
recovery = _recovery(restarted.base)
|
||||
assert recovery["items"], f"a crash at {crash_point} must leave visible evidence"
|
||||
assert recovery["items"][0]["journal_state"] == crash_point
|
||||
# Only a crash that could have left the library half-renamed blocks other
|
||||
# work. `verified` is past every filesystem and database change — the move
|
||||
# is done and checked, just not flagged complete — so it blocks nothing.
|
||||
assert recovery["blocks_mutation"] is (crash_point != "verified")
|
||||
|
||||
resolved = httpx.post(
|
||||
f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT
|
||||
).json()
|
||||
assert resolved["manual"] == 0, "an interrupted rename must be decidable from evidence"
|
||||
|
||||
# Recovery is idempotent: running it again changes nothing.
|
||||
assert _recovery(restarted.base)["blocks_mutation"] is False
|
||||
httpx.post(f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT)
|
||||
|
||||
# A resumable crash is left ready to run again; finish it so every crash
|
||||
# point converges on the same observable end state.
|
||||
current = _get_plan(restarted.base, plan["id"])
|
||||
if current["state"] != "applied":
|
||||
_apply(restarted.base, current)
|
||||
|
||||
after = _inventory(restarted.base)
|
||||
assert set(after) == set(before), "no asset may be lost or invented by a crash"
|
||||
assert {item["current_sha256"] for item in after.values()} == {
|
||||
item["current_sha256"] for item in before.values()
|
||||
}
|
||||
assert sorted(_content(seeded.lib).values()) == before_bytes
|
||||
assert (seeded.lib / APPROVED).is_dir() and not (seeded.lib / "rome").exists()
|
||||
assert all(APPROVED in item["current_path"] for item in after.values())
|
||||
assert _journal_states(seeded, plan["id"]) == ["complete"]
|
||||
finally:
|
||||
restarted.stop()
|
||||
|
||||
|
||||
def test_ambiguous_evidence_is_kept_for_a_human_and_keeps_blocking(tmp_path):
|
||||
seeded = seed_album(tmp_path)
|
||||
first = Server(seeded).start()
|
||||
try:
|
||||
approve_album(first.base, name=APPROVED)
|
||||
plan = _plan(first.base)
|
||||
finally:
|
||||
first.stop()
|
||||
|
||||
_crash_during_apply(seeded, plan, "moving")
|
||||
# Someone creates the destination while the operation is unresolved: now both
|
||||
# paths exist and nothing can tell which one holds the truth.
|
||||
(seeded.lib / APPROVED).mkdir(exist_ok=True)
|
||||
|
||||
restarted = Server(seeded).start()
|
||||
try:
|
||||
assert _recovery(restarted.base)["items"][0]["classification"] == "manual"
|
||||
resolved = httpx.post(
|
||||
f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT
|
||||
).json()
|
||||
assert resolved["manual"] == 1 and resolved["resumed"] == 0 and resolved["completed"] == 0
|
||||
# Still blocking, and still nothing guessed.
|
||||
assert _recovery(restarted.base)["blocks_mutation"] is True
|
||||
assert (seeded.lib / "rome").is_dir()
|
||||
finally:
|
||||
restarted.stop()
|
||||
|
||||
|
||||
def test_an_unresolved_rename_is_the_cancellation_boundary(tmp_path):
|
||||
"""There is no cancel button once a rename starts. The boundary is that nothing
|
||||
else may mutate the library until the interrupted work is resolved."""
|
||||
seeded = seed_album(tmp_path)
|
||||
first = Server(seeded).start()
|
||||
try:
|
||||
approve_album(first.base, name=APPROVED)
|
||||
plan = _plan(first.base)
|
||||
finally:
|
||||
first.stop()
|
||||
|
||||
_crash_during_apply(seeded, plan, "moving")
|
||||
|
||||
restarted = Server(seeded).start()
|
||||
try:
|
||||
assert _recovery(restarted.base)["blocks_mutation"] is True
|
||||
|
||||
refused = httpx.post(f"{restarted.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
|
||||
assert refused.status_code == 409
|
||||
assert refused.json()["error"]["code"] == "rename_recovery_required"
|
||||
|
||||
# Reading stays available throughout — only mutation is paused.
|
||||
assert httpx.get(f"{restarted.base}/api/v1/albums/evidence", timeout=TIMEOUT).status_code
|
||||
assert len(_inventory(restarted.base)) == 2
|
||||
finally:
|
||||
restarted.stop()
|
||||
|
||||
|
||||
def test_rollback_returns_an_interrupted_move_to_its_source(tmp_path):
|
||||
seeded = seed_album(tmp_path)
|
||||
first = Server(seeded).start()
|
||||
try:
|
||||
approve_album(first.base, name=APPROVED)
|
||||
before = _inventory(first.base)
|
||||
plan = _plan(first.base)
|
||||
finally:
|
||||
first.stop()
|
||||
|
||||
# Crash after the content moved but before the database caught up: the operation
|
||||
# is still reversible, which is exactly when rollback is defined.
|
||||
_crash_during_apply(seeded, plan, "moved")
|
||||
|
||||
restarted = Server(seeded).start()
|
||||
try:
|
||||
rolled = httpx.post(
|
||||
f"{restarted.base}/api/v1/rename-plans/{plan['id']}/rollback", timeout=TIMEOUT
|
||||
).json()
|
||||
assert rolled["rolled_back"] == 1 and rolled["state"] == "rolled_back"
|
||||
|
||||
assert (seeded.lib / "rome" / "a.jpg").exists()
|
||||
assert not (seeded.lib / APPROVED).exists()
|
||||
after = _inventory(restarted.base)
|
||||
assert set(after) == set(before)
|
||||
assert all(item["current_path"].endswith(".jpg") for item in after.values())
|
||||
assert _recovery(restarted.base)["blocks_mutation"] is False
|
||||
finally:
|
||||
restarted.stop()
|
||||
|
||||
|
||||
# ── durability ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_applied_state_survives_a_full_restart(tmp_path):
|
||||
seeded = seed_album(tmp_path)
|
||||
first = Server(seeded).start()
|
||||
try:
|
||||
approve_album(first.base, name=APPROVED)
|
||||
plan = _plan(first.base)
|
||||
_apply(first.base, plan, expected_checksum=plan["checksum"]).raise_for_status()
|
||||
expected = _inventory(first.base)
|
||||
finally:
|
||||
first.stop()
|
||||
|
||||
restarted = Server(seeded).start()
|
||||
try:
|
||||
assert _inventory(restarted.base) == expected
|
||||
after = _get_plan(restarted.base, plan["id"])
|
||||
assert after["state"] == "applied"
|
||||
assert after["checksum"] == plan["checksum"], "the plan's evidence is immutable"
|
||||
assert [op["journal_state"] for op in after["operations"]] == ["complete"]
|
||||
assert all(op["verified_at"] for op in after["operations"])
|
||||
assert _recovery(restarted.base) == {"blocks_mutation": False, "items": []}
|
||||
finally:
|
||||
restarted.stop()
|
||||
@@ -1,294 +0,0 @@
|
||||
"""Browser journeys for the rename view (US04-05).
|
||||
|
||||
Covers previewing every affected path, confirming with the server-issued token,
|
||||
applying with visible progress and terminal verification, refusing a stale
|
||||
confirmation, previewing a collision as blocking, and resolving an interrupted
|
||||
rename — including the check that an unresolved rename really does block unrelated
|
||||
mutations at the API, not just in the UI.
|
||||
|
||||
Renames really happen here: the assertions read the filesystem afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory
|
||||
|
||||
# Part of the Phase D acceptance command (US04-06); mapped to US04-05 for traceability.
|
||||
pytestmark = pytest.mark.phase_d
|
||||
|
||||
TIMEOUT = 10
|
||||
APPROVED = "2019 Rome"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(tmp_path):
|
||||
seeded = seed_album(tmp_path)
|
||||
running = Server(seeded).start()
|
||||
running.seeded = seeded
|
||||
try:
|
||||
yield running
|
||||
finally:
|
||||
running.stop()
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _approve(base, *, name=APPROVED):
|
||||
approve_album(base, name=name)
|
||||
|
||||
|
||||
def _build(base):
|
||||
response = httpx.post(f"{base}/api/v1/rename-plans", timeout=TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _open_plan(page, server):
|
||||
"""Approve a name, open the view, and build the plan through the UI."""
|
||||
_approve(server.base)
|
||||
page.goto(f"{server.base}/app/#/renames")
|
||||
page.get_by_test_id("build-plan").click()
|
||||
page.get_by_test_id("operations").wait_for()
|
||||
|
||||
|
||||
def _interrupt(seeded, plan, *, state="moving", make_destination=False):
|
||||
"""Leave an operation in a non-terminal journal state, as a crash mid-apply would.
|
||||
|
||||
``make_destination`` also creates the destination folder, so source and
|
||||
destination both exist and the evidence becomes ambiguous (``manual``).
|
||||
"""
|
||||
from photo_pipeline.services.rename_journal import RenameJournal
|
||||
|
||||
with session_factory(seeded) as sf:
|
||||
journal = RenameJournal(sf)
|
||||
operation = journal.operations(plan["id"])[0]
|
||||
journal.begin(operation["id"], worker_id="crashed", fencing_token=1)
|
||||
if state != "moving":
|
||||
journal.transition(operation["id"], state, fencing_token=1)
|
||||
if make_destination:
|
||||
(seeded.lib / APPROVED).mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ── preview ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_preview_shows_every_path_operation_type_and_count(page, server):
|
||||
errors = []
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
_open_plan(page, server)
|
||||
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("validated")
|
||||
expect(page.get_by_test_id("operation-count")).to_have_text("1 folder")
|
||||
expect(page.get_by_test_id("affected-assets")).to_have_text("2 photos")
|
||||
|
||||
row = page.get_by_test_id("operation-row").first
|
||||
# Whole paths, both sides — nothing about the move is left to be inferred.
|
||||
assert row.get_by_test_id("op-source").inner_text().endswith("/rome")
|
||||
assert row.get_by_test_id("op-destination").inner_text().endswith(f"/{APPROVED}")
|
||||
expect(row.get_by_test_id("op-type")).to_have_text("rename")
|
||||
expect(row.get_by_test_id("op-count")).to_have_text("2")
|
||||
expect(row.get_by_test_id("op-state")).to_have_text("planned")
|
||||
assert errors == [], f"console errors: {errors}"
|
||||
|
||||
|
||||
def test_confirmation_shows_the_server_token_and_the_cancellation_limit(page, server):
|
||||
_open_plan(page, server)
|
||||
plan = httpx.get(f"{server.base}/api/v1/rename-plans", timeout=TIMEOUT).json()["items"][0]
|
||||
|
||||
token = page.get_by_test_id("confirm-token").inner_text()
|
||||
assert plan["id"] in token and f"version {plan['version']}" in token
|
||||
assert plan["checksum"][:12] in token
|
||||
# The limit on cancellation is stated before the button, not discovered after.
|
||||
assert "cannot be cancelled" in page.get_by_test_id("cancel-note").inner_text()
|
||||
|
||||
|
||||
def test_a_collision_is_previewed_as_blocking_and_cannot_be_applied(page, server):
|
||||
_approve(server.base)
|
||||
# Someone else already owns the destination name, with content worth keeping.
|
||||
(server.seeded.lib / APPROVED).mkdir()
|
||||
(server.seeded.lib / APPROVED / "precious.jpg").write_bytes(b"do not lose me")
|
||||
|
||||
page.goto(f"{server.base}/app/#/renames")
|
||||
page.get_by_test_id("build-plan").click()
|
||||
page.get_by_test_id("operations").wait_for()
|
||||
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("invalid")
|
||||
expect(page.get_by_test_id("plan-blockers")).to_contain_text("destination_exists")
|
||||
issue = page.get_by_test_id("op-issue").first
|
||||
expect(issue).to_have_attribute("data-code", "destination_exists")
|
||||
assert issue.inner_text().startswith("blocks")
|
||||
expect(page.get_by_test_id("apply-plan")).to_be_disabled()
|
||||
|
||||
assert (server.seeded.lib / APPROVED / "precious.jpg").read_bytes() == b"do not lose me"
|
||||
assert (server.seeded.lib / "rome").is_dir()
|
||||
|
||||
|
||||
# ── apply ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_reports_progress_terminal_verification_and_moves_the_folder(page, server):
|
||||
_open_plan(page, server)
|
||||
# The progress region is live before anything runs, so the status it announces
|
||||
# during the run reaches assistive technology.
|
||||
progress = page.get_by_test_id("apply-progress")
|
||||
expect(progress).to_have_attribute("aria-live", "polite")
|
||||
|
||||
page.get_by_test_id("apply-plan").click()
|
||||
|
||||
expect(page.get_by_test_id("apply-result")).to_contain_text("Applied 1, failed 0, skipped 0")
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
row = page.get_by_test_id("operation-row").first
|
||||
expect(row.get_by_test_id("op-state")).to_have_text("complete")
|
||||
expect(row.get_by_test_id("op-verified")).to_be_visible()
|
||||
|
||||
assert (server.seeded.lib / APPROVED / "a.jpg").exists()
|
||||
assert not (server.seeded.lib / "rome").exists()
|
||||
|
||||
|
||||
def test_a_stale_confirmation_is_refused_and_explained(page, server):
|
||||
_open_plan(page, server)
|
||||
# Another client applies first, which bumps the plan version this page is holding.
|
||||
plan = httpx.get(f"{server.base}/api/v1/rename-plans", timeout=TIMEOUT).json()["items"][0]
|
||||
httpx.post(
|
||||
f"{server.base}/api/v1/rename-plans/{plan['id']}/apply",
|
||||
json={"expected_version": plan["version"]},
|
||||
timeout=TIMEOUT,
|
||||
).raise_for_status()
|
||||
|
||||
page.get_by_test_id("apply-plan").click()
|
||||
|
||||
expect(page.get_by_test_id("conflict")).to_be_visible()
|
||||
# The refusal is not a second run: the page now shows the server's real state.
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
expect(page.get_by_test_id("apply-plan")).to_be_disabled()
|
||||
|
||||
|
||||
def test_the_applied_plan_survives_a_reload(page, server):
|
||||
_open_plan(page, server)
|
||||
page.get_by_test_id("apply-plan").click()
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
|
||||
page.reload()
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
expect(page.get_by_test_id("operation-row").first.get_by_test_id("op-state")).to_have_text(
|
||||
"complete"
|
||||
)
|
||||
# The result banner is tab state, not server state, so it correctly does not return.
|
||||
expect(page.get_by_test_id("apply-result")).to_have_count(0)
|
||||
|
||||
|
||||
def test_the_view_matches_the_journal_after_a_server_restart(page, server):
|
||||
_open_plan(page, server)
|
||||
page.get_by_test_id("apply-plan").click()
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
|
||||
server.stop()
|
||||
server.start() # same port, so the page reconnects to a genuinely fresh process
|
||||
|
||||
page.reload()
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
expect(page.get_by_test_id("operation-row").first.get_by_test_id("op-state")).to_have_text(
|
||||
"complete"
|
||||
)
|
||||
expect(page.get_by_test_id("op-verified")).to_be_visible()
|
||||
|
||||
|
||||
def test_apply_can_be_confirmed_from_the_keyboard(page, server):
|
||||
_open_plan(page, server)
|
||||
page.get_by_test_id("apply-plan").focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
assert (server.seeded.lib / APPROVED).is_dir()
|
||||
|
||||
|
||||
def test_a_finished_rename_offers_no_rollback(page, server):
|
||||
"""Rollback is recovery, not undo: once an operation is `complete` the journal
|
||||
treats it as terminal, so the view must not suggest it can be reversed."""
|
||||
_open_plan(page, server)
|
||||
page.get_by_test_id("apply-plan").click()
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
|
||||
expect(page.get_by_test_id("rollback-plan")).to_have_count(0)
|
||||
|
||||
|
||||
def test_rollback_returns_an_unfinished_move_to_its_source(page, server):
|
||||
_approve(server.base)
|
||||
plan = _build(server.base)
|
||||
# A crash after the move but before the database caught up: the content sits at
|
||||
# the destination and the operation is still reversible.
|
||||
_interrupt(server.seeded, plan, state="moving")
|
||||
(server.seeded.lib / "rome").rename(server.seeded.lib / APPROVED)
|
||||
with session_factory(server.seeded) as sf:
|
||||
from photo_pipeline.services.rename_journal import RenameJournal
|
||||
|
||||
journal = RenameJournal(sf)
|
||||
journal.transition(journal.operations(plan["id"])[0]["id"], "moved", fencing_token=1)
|
||||
|
||||
page.goto(f"{server.base}/app/#/renames")
|
||||
page.get_by_test_id("rollback-plan").click()
|
||||
|
||||
expect(page.get_by_test_id("plan-state")).to_have_text("rolled_back")
|
||||
assert (server.seeded.lib / "rome" / "a.jpg").exists()
|
||||
assert not (server.seeded.lib / APPROVED).exists()
|
||||
|
||||
|
||||
# ── recovery ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_an_interrupted_rename_is_shown_and_offers_the_safe_action(page, server):
|
||||
_approve(server.base)
|
||||
plan = _build(server.base)
|
||||
_interrupt(server.seeded, plan) # source intact, destination free → resumable
|
||||
|
||||
page.goto(f"{server.base}/app/#/renames")
|
||||
banner = page.get_by_test_id("recovery-banner")
|
||||
banner.wait_for()
|
||||
expect(page.get_by_test_id("recovery-item")).to_have_attribute(
|
||||
"data-classification", "resumable"
|
||||
)
|
||||
# While it is unresolved nothing else may mutate the library.
|
||||
expect(page.get_by_test_id("build-plan")).to_be_disabled()
|
||||
expect(page.get_by_test_id("apply-plan")).to_be_disabled()
|
||||
|
||||
page.get_by_test_id("resolve-recovery").click()
|
||||
expect(banner).to_have_count(0)
|
||||
expect(page.get_by_test_id("build-plan")).to_be_enabled()
|
||||
|
||||
|
||||
def test_ambiguous_evidence_offers_no_automatic_action(page, server):
|
||||
_approve(server.base)
|
||||
plan = _build(server.base)
|
||||
# Both paths exist: nothing can tell which side holds the truth.
|
||||
_interrupt(server.seeded, plan, make_destination=True)
|
||||
|
||||
page.goto(f"{server.base}/app/#/renames")
|
||||
page.get_by_test_id("recovery-banner").wait_for()
|
||||
expect(page.get_by_test_id("recovery-item")).to_have_attribute("data-classification", "manual")
|
||||
expect(page.get_by_test_id("manual-only")).to_be_visible()
|
||||
# No button is offered for work the server would refuse to do.
|
||||
expect(page.get_by_test_id("resolve-recovery")).to_have_count(0)
|
||||
|
||||
|
||||
def test_an_unresolved_rename_blocks_unrelated_name_changes(page, server):
|
||||
_approve(server.base)
|
||||
plan = _build(server.base)
|
||||
_interrupt(server.seeded, plan, make_destination=True)
|
||||
|
||||
page.goto(f"{server.base}/app/#/albums?album=rome")
|
||||
expect(page.get_by_test_id("rename-blocked")).to_be_visible()
|
||||
expect(page.get_by_test_id("approve")).to_be_disabled()
|
||||
expect(page.get_by_test_id("generate-proposals")).to_be_disabled()
|
||||
|
||||
# The disabled buttons are not the only guard: the API refuses too.
|
||||
current = httpx.get(f"{server.base}/api/v1/albums/proposals/rome", timeout=TIMEOUT).json()
|
||||
refused = httpx.post(
|
||||
f"{server.base}/api/v1/albums/proposals/rome/approve",
|
||||
json={"expected_version": current["version"]},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert refused.status_code == 409
|
||||
assert refused.json()["error"]["code"] == "rename_recovery_required"
|
||||
@@ -12,19 +12,12 @@ from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
MAP = json.loads((REPO / "tests" / "story_traceability.json").read_text())["stories"]
|
||||
PHASE_A_STORIES = {f"US01-0{n}" for n in range(1, 8)}
|
||||
PHASE_D_STORIES = {f"US04-0{n}" for n in range(1, 7)}
|
||||
|
||||
|
||||
def test_all_phase_a_stories_are_mapped():
|
||||
assert PHASE_A_STORIES <= set(MAP)
|
||||
|
||||
|
||||
def test_all_phase_d_stories_are_mapped():
|
||||
"""US04-06 acceptance: every guarded-rename story, plan through browser, is tied
|
||||
to automated tests — renaming is the first thing that mutates the real library."""
|
||||
assert PHASE_D_STORIES <= set(MAP)
|
||||
|
||||
|
||||
def test_every_mapped_test_file_exists_and_is_nonempty():
|
||||
for story, files in MAP.items():
|
||||
assert files, f"{story} maps to no tests"
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
"""Upload preflight: credentials, scope, readiness, and tokens (US05-01).
|
||||
|
||||
Every external boundary is faked but never mocked away: the uploader is a real
|
||||
executable on disk invoked through ``subprocess``, and the Immich server is a real
|
||||
localhost HTTP server answering ``/api/server/ping``. Preflight itself must stay
|
||||
read-only — the library snapshot is asserted unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
import stat
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from photo_pipeline.api.app import create_app
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||
from photo_pipeline.models import (
|
||||
AnalysisResult,
|
||||
Asset,
|
||||
RenameOperation,
|
||||
RenamePlan,
|
||||
SafetyReview,
|
||||
)
|
||||
from photo_pipeline.services.hashing import sha256_file
|
||||
from photo_pipeline.services.uploads import UploadError, UploadService
|
||||
|
||||
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
# A sentinel credential: every assertion below proves it never leaves configuration.
|
||||
SENTINEL_KEY = "immich-sentinel-9f3a2b"
|
||||
UPLOADER_VERSION = "immich-go 0.21.0"
|
||||
|
||||
|
||||
# ── fake external boundary ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _PingHandler(BaseHTTPRequestHandler):
|
||||
payload = b'{"res":"pong"}'
|
||||
status = 200
|
||||
|
||||
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
|
||||
self.send_response(type(self).status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(type(self).payload)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass # keep the test output clean
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immich_server():
|
||||
"""A real HTTP server that answers like Immich. Yields its base URL."""
|
||||
handler = type("Handler", (_PingHandler,), {})
|
||||
server = HTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{server.server_port}", handler
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _fake_uploader(tmp_path):
|
||||
"""A real executable standing in for immich-go."""
|
||||
path = tmp_path / "immich-go"
|
||||
path.write_text(f"#!/bin/sh\necho '{UPLOADER_VERSION}'\n")
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
||||
|
||||
# ── environment ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _env(tmp_path, server_url, *, credential=SENTINEL_KEY, uploader=None):
|
||||
(tmp_path / "data").mkdir(exist_ok=True)
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir(exist_ok=True)
|
||||
env = {
|
||||
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
|
||||
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
|
||||
"PHOTO_PIPELINE_IMMICH_SERVER_URL": server_url,
|
||||
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(
|
||||
uploader if uploader is not None else _fake_uploader(tmp_path)
|
||||
),
|
||||
}
|
||||
if credential:
|
||||
env["PHOTO_PIPELINE_IMMICH_API_KEY"] = credential
|
||||
config = Config.from_env(env)
|
||||
run_migrations(config.database_url)
|
||||
return config, create_session_factory(create_db_engine(config.database_url)), lib
|
||||
|
||||
|
||||
def _album(
|
||||
sf,
|
||||
lib,
|
||||
album="rome",
|
||||
names=("a.jpg", "b.jpg"),
|
||||
*,
|
||||
decision="sfw",
|
||||
exif_verified=True,
|
||||
analyzed=True,
|
||||
):
|
||||
"""A real album folder with registered, stage-complete assets."""
|
||||
folder = lib / album
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
ids = []
|
||||
with sf() as session:
|
||||
for name in names:
|
||||
path = folder / name
|
||||
path.write_bytes(name.encode() * 16)
|
||||
asset_id = str(uuid.uuid4())
|
||||
ids.append(asset_id)
|
||||
session.add(
|
||||
Asset(
|
||||
id=asset_id,
|
||||
original_path=str(path),
|
||||
current_path=str(path),
|
||||
discovered_at=NOW,
|
||||
hash_version=1,
|
||||
byte_size=path.stat().st_size,
|
||||
current_sha256=sha256_file(path),
|
||||
)
|
||||
)
|
||||
if decision is not None:
|
||||
session.add(
|
||||
SafetyReview(
|
||||
id=str(uuid.uuid4()),
|
||||
asset_id=asset_id,
|
||||
decision=decision,
|
||||
exif_verified_at=NOW if exif_verified else None,
|
||||
)
|
||||
)
|
||||
if analyzed:
|
||||
session.add(
|
||||
AnalysisResult(
|
||||
asset_id=asset_id,
|
||||
status="analyzed",
|
||||
exif_written_at=NOW,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return folder, ids
|
||||
|
||||
|
||||
def _snapshot(lib):
|
||||
return {
|
||||
str(p.relative_to(lib)): (p.read_bytes() if p.is_file() else None)
|
||||
for p in sorted(lib.rglob("*"))
|
||||
}
|
||||
|
||||
|
||||
def _service(sf, config):
|
||||
return UploadService(sf, config=config)
|
||||
|
||||
|
||||
def _codes(report):
|
||||
return {issue["code"] for issue in report["blockers"]} | {
|
||||
issue["code"] for album in report["albums"] for issue in album["blockers"]
|
||||
} | {
|
||||
issue["code"]
|
||||
for album in report["albums"]
|
||||
for asset in album["assets"]
|
||||
for issue in asset["blockers"]
|
||||
}
|
||||
|
||||
|
||||
# ── happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ready_preflight_reports_scope_command_and_hashes(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, ids = _album(sf, lib)
|
||||
before = _snapshot(lib)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert report["state"] == "ready" and report["blockers"] == []
|
||||
assert report["server"]["reachable"] is True
|
||||
assert report["uploader"]["installed"] is True
|
||||
assert report["uploader"]["version"] == UPLOADER_VERSION
|
||||
assert report["credentials"]["api_key_configured"] is True
|
||||
assert report["totals"] == {
|
||||
"albums": 1,
|
||||
"ready_albums": 1,
|
||||
"assets": 2,
|
||||
"eligible": 2,
|
||||
"blocked": 0,
|
||||
}
|
||||
album = report["albums"][0]
|
||||
assert album["album"] == "rome" and album["folder"] == str(folder)
|
||||
assert album["album_name"] == "rome" # folder-as-album
|
||||
assert album["partial"] is False and album["state"] == "ready"
|
||||
assert sorted(a["asset_id"] for a in album["assets"]) == sorted(ids)
|
||||
# Hashes are of the bytes on disk right now, not a remembered value.
|
||||
for asset in album["assets"]:
|
||||
assert asset["current_sha256"] == sha256_file(asset["current_path"])
|
||||
assert report["token"].startswith("v1:")
|
||||
assert _snapshot(lib) == before, "preflight must not touch the library"
|
||||
|
||||
|
||||
def test_command_preview_shows_folder_and_album_without_the_key(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, _ = _album(sf, lib)
|
||||
|
||||
preview = _service(sf, config).preflight()["albums"][0]["command_preview"]
|
||||
|
||||
assert f"--api-key={'***'}" in preview
|
||||
assert "--album-name=rome" in preview
|
||||
assert str(folder) in preview
|
||||
assert SENTINEL_KEY not in " ".join(preview)
|
||||
|
||||
|
||||
def test_scoping_to_one_album_excludes_the_others(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib, "rome")
|
||||
_album(sf, lib, "paris", names=("c.jpg",))
|
||||
|
||||
report = _service(sf, config).preflight(["paris"])
|
||||
|
||||
assert [album["album"] for album in report["albums"]] == ["paris"]
|
||||
assert report["totals"]["assets"] == 1
|
||||
|
||||
|
||||
def test_unknown_album_in_scope_is_refused(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib, "rome")
|
||||
|
||||
with pytest.raises(UploadError):
|
||||
_service(sf, config).preflight(["atlantis"])
|
||||
|
||||
|
||||
def test_empty_scope_is_a_blocker(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, _ = _env(tmp_path, url)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert report["state"] == "blocked" and "empty_scope" in _codes(report)
|
||||
|
||||
|
||||
# ── credentials and environment ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_missing_api_key_blocks_without_touching_the_server(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url, credential=None)
|
||||
_album(sf, lib)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert report["state"] == "blocked"
|
||||
assert "credentials_missing" in _codes(report)
|
||||
assert report["credentials"]["api_key_configured"] is False
|
||||
|
||||
|
||||
def test_unreachable_server_blocks(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url.replace(url.rsplit(":", 1)[-1], "1"))
|
||||
_album(sf, lib)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert "server_unreachable" in _codes(report)
|
||||
assert report["server"]["reachable"] is False and report["server"]["detail"]
|
||||
|
||||
|
||||
def test_server_that_is_not_immich_blocks(tmp_path, immich_server):
|
||||
url, handler = immich_server
|
||||
handler.payload = b'{"error":"unauthorized"}'
|
||||
handler.status = 401
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib)
|
||||
|
||||
assert "server_unreachable" in _codes(_service(sf, config).preflight())
|
||||
|
||||
|
||||
def test_missing_uploader_blocks(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url, uploader=tmp_path / "does-not-exist")
|
||||
_album(sf, lib)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert "immich_go_missing" in _codes(report)
|
||||
assert report["uploader"]["installed"] is False and report["uploader"]["version"] is None
|
||||
|
||||
|
||||
def test_half_applied_rename_blocks_upload(tmp_path, immich_server):
|
||||
"""Album paths must be final before upload: an operation that may have already
|
||||
touched the disk blocks every other mutation until it is recovered (US04-04)."""
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, _ = _album(sf, lib)
|
||||
with sf() as session:
|
||||
plan_id = str(uuid.uuid4())
|
||||
session.add(RenamePlan(id=plan_id, state="applying", operation_count=1))
|
||||
session.flush()
|
||||
session.add(
|
||||
RenameOperation(
|
||||
id=str(uuid.uuid4()),
|
||||
plan_id=plan_id,
|
||||
sequence=0,
|
||||
operation="move_folder",
|
||||
source_path=str(folder),
|
||||
destination_path=str(lib / "2019 Rome"),
|
||||
journal_state="moving",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert report["state"] == "blocked" and "rename_pending" in _codes(report)
|
||||
|
||||
|
||||
def test_no_secret_appears_anywhere_in_the_report(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert SENTINEL_KEY not in json.dumps(report, default=str)
|
||||
|
||||
|
||||
# ── stage readiness ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs,code",
|
||||
[
|
||||
({"decision": None}, "safety_undecided"),
|
||||
({"decision": "deferred"}, "safety_deferred"),
|
||||
({"exif_verified": False}, "safety_exif_unverified"),
|
||||
({"analyzed": False}, "analysis_incomplete"),
|
||||
],
|
||||
)
|
||||
def test_blocked_stage_blocks_upload(tmp_path, immich_server, kwargs, code):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib, **kwargs)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert report["state"] == "blocked"
|
||||
assert code in _codes(report)
|
||||
assert report["albums"][0]["eligible_count"] == 0
|
||||
|
||||
|
||||
def test_reviewed_nsfw_asset_is_upload_eligible_without_analysis(tmp_path, immich_server):
|
||||
"""NSFW never reaches the analyser, but a verified nsfw keyword makes it
|
||||
uploadable (concept §8 eligibility table)."""
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib, decision="nsfw", analyzed=False)
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert report["state"] == "ready"
|
||||
assert report["albums"][0]["eligible_count"] == 2
|
||||
|
||||
|
||||
def test_changed_bytes_block_the_album(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, _ = _album(sf, lib)
|
||||
(folder / "a.jpg").write_bytes(b"edited after the checkpoint")
|
||||
|
||||
report = _service(sf, config).preflight()
|
||||
|
||||
assert "bytes_changed" in _codes(report)
|
||||
assert report["albums"][0]["blocked_count"] == 1
|
||||
|
||||
|
||||
def test_missing_file_blocks_the_album(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, _ = _album(sf, lib)
|
||||
(folder / "a.jpg").unlink()
|
||||
|
||||
assert "file_missing" in _codes(_service(sf, config).preflight())
|
||||
|
||||
|
||||
# ── partial scope ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_partial_album_is_blocked_until_explicitly_approved(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, _ = _album(sf, lib)
|
||||
(folder / "a.jpg").write_bytes(b"changed")
|
||||
|
||||
blocked = _service(sf, config).preflight()
|
||||
approved = _service(sf, config).preflight(allow_partial=True)
|
||||
|
||||
assert blocked["state"] == "blocked"
|
||||
assert "partial_scope" in {issue["code"] for issue in blocked["albums"][0]["blockers"]}
|
||||
assert approved["state"] == "ready"
|
||||
assert approved["albums"][0]["partial"] is True
|
||||
assert approved["albums"][0]["eligible_count"] == 1
|
||||
# The approval is part of the token, so it can never be replayed as a full run.
|
||||
assert approved["token"] != blocked["token"]
|
||||
|
||||
|
||||
def test_partial_approval_cannot_rescue_an_album_with_nothing_ready(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib, decision=None)
|
||||
|
||||
report = _service(sf, config).preflight(allow_partial=True)
|
||||
|
||||
assert report["state"] == "blocked"
|
||||
assert "empty_scope" in {issue["code"] for issue in report["albums"][0]["blockers"]}
|
||||
|
||||
|
||||
# ── token ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_token_is_stable_while_nothing_relevant_changes(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib)
|
||||
service = _service(sf, config)
|
||||
|
||||
first = service.preflight()["token"]
|
||||
|
||||
assert service.preflight()["token"] == first
|
||||
assert service.verify_token(first) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", [None, ["rome"]])
|
||||
def test_edited_bytes_make_the_token_stale(tmp_path, immich_server, scope):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
folder, _ = _album(sf, lib)
|
||||
service = _service(sf, config)
|
||||
token = service.preflight(scope)["token"]
|
||||
|
||||
(folder / "b.jpg").write_bytes(b"edited outside the app")
|
||||
|
||||
assert service.verify_token(token, scope) is False
|
||||
|
||||
|
||||
def test_changed_decision_makes_the_token_stale(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_, ids = _album(sf, lib)
|
||||
service = _service(sf, config)
|
||||
token = service.preflight()["token"]
|
||||
|
||||
with sf() as session:
|
||||
session.add(
|
||||
SafetyReview(
|
||||
id=str(uuid.uuid4()),
|
||||
asset_id=ids[0],
|
||||
decision="deferred",
|
||||
created_at=datetime(2099, 1, 1, tzinfo=timezone.utc), # the latest review wins
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert service.verify_token(token) is False
|
||||
|
||||
|
||||
def test_token_from_a_different_scope_is_rejected(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib, "rome")
|
||||
_album(sf, lib, "paris", names=("c.jpg",))
|
||||
service = _service(sf, config)
|
||||
|
||||
assert service.verify_token(service.preflight(["rome"])["token"], ["paris"]) is False
|
||||
assert service.verify_token("v1:not-a-real-token") is False
|
||||
assert service.verify_token("") is False
|
||||
|
||||
|
||||
# ── API surface ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_api_preflight_returns_the_report_without_secrets(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib)
|
||||
|
||||
with TestClient(create_app(config)) as client:
|
||||
response = client.post("/api/v1/upload-preflight", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["state"] == "ready" and body["token"].startswith("v1:")
|
||||
assert SENTINEL_KEY not in response.text
|
||||
|
||||
|
||||
def test_api_rejects_an_unknown_album(tmp_path, immich_server):
|
||||
url, _ = immich_server
|
||||
config, sf, lib = _env(tmp_path, url)
|
||||
_album(sf, lib)
|
||||
|
||||
with TestClient(create_app(config)) as client:
|
||||
response = client.post("/api/v1/upload-preflight", json={"albums": ["atlantis"]})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["error"]["code"] == "unknown_album"
|
||||
@@ -97,15 +97,6 @@
|
||||
],
|
||||
"US04-04": [
|
||||
"tests/integration/test_rename_recovery.py"
|
||||
],
|
||||
"US04-05": [
|
||||
"tests/e2e/test_renames_ui.py"
|
||||
],
|
||||
"US04-06": [
|
||||
"tests/e2e/test_phase_d_pipeline.py"
|
||||
],
|
||||
"US05-01": [
|
||||
"tests/integration/test_upload_preflight.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user