faces: fix PathExcluder false-positive + cover face_client/crop in tests

PathExcluder was iterating every component of the absolute path,
including the system prefix. Two of the existing memories tests had
been failing on master because tempdir() lives under /tmp on Linux
and a pattern like "tmp" then matched the system /tmp component
rather than anything the user actually asked to exclude. Phase 3's
file-watch hook will use the same code to skip @eaDir / .thumbnails
under each library's BASE_PATH, so the bug would hide every photo
on a host whose BASE_PATH passes through a directory named the same
as a user pattern.

Fix: store base in PathExcluder and strip it before scanning
components. A path that lives outside base falls through to the
no-match branch (defensive — nothing legit hits that today).

Also extracted the face_client error classification into a pure
classify_error_response(status, body) so the marker-row contract
with Apollo (422 → Permanent / 'failed', 5xx → Transient / defer)
is unit-testable without spinning up an HTTP server.

New tests:
  memories::tests::test_path_excluder_*           — 2 previously
    failing tests now pass.
  ai::face_client::tests::classify_*              — 4 cases:
    422 decode_failed → Permanent, 503 cuda_oom → Transient
    (handles both string and {code:..} detail shapes), 5xx →
    Transient + other 4xx → Permanent, unparseable HTML body still
    classifies on status.
  faces::tests::crop_*                            — 3 cases:
    invalid bbox rejected, valid bbox round-trips through JPEG
    decode, corner crop with 10% padding clamps inside source.

cargo test --lib: 165 passed / 0 failed (was 156 / 2 failed).
cargo fmt and clippy on new code clean. The remaining
sort_by clippy warnings in pre-existing files (memories.rs,
files.rs, exif.rs) are unrelated and present on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-04-29 18:09:44 +00:00
parent 860169032b
commit f77e44b34d
3 changed files with 200 additions and 71 deletions

View File

@@ -1860,4 +1860,65 @@ mod tests {
assert_eq!(faces.len(), 1);
assert_eq!(faces[0].person_id, Some(alice.id));
}
// ── crop_image_to_bbox ──────────────────────────────────────────────
// Pure helper used by the manual face-create handler. Generate a tiny
// image in memory, write it to a temp file, then exercise the bbox
// validation + crop math.
fn write_solid_image(w: u32, h: u32) -> tempfile::NamedTempFile {
let mut img = image::RgbImage::new(w, h);
for p in img.pixels_mut() {
*p = image::Rgb([200, 200, 200]);
}
let f = tempfile::Builder::new()
.suffix(".jpg")
.tempfile()
.expect("tempfile");
image::DynamicImage::ImageRgb8(img)
.save(f.path())
.expect("save jpg");
f
}
#[test]
fn crop_rejects_invalid_bbox() {
let f = write_solid_image(64, 64);
// x out of [0,1]
assert!(crop_image_to_bbox(f.path(), -0.1, 0.0, 0.5, 0.5).is_err());
assert!(crop_image_to_bbox(f.path(), 1.5, 0.0, 0.5, 0.5).is_err());
// zero / negative dimensions
assert!(crop_image_to_bbox(f.path(), 0.0, 0.0, 0.0, 0.5).is_err());
assert!(crop_image_to_bbox(f.path(), 0.0, 0.0, 0.5, -0.1).is_err());
// overflows the image
assert!(crop_image_to_bbox(f.path(), 0.7, 0.0, 0.5, 0.5).is_err());
}
#[test]
fn crop_returns_decodable_jpeg() {
let f = write_solid_image(200, 200);
let bytes = crop_image_to_bbox(f.path(), 0.25, 0.25, 0.5, 0.5).expect("center crop");
// Re-decode to confirm the pipeline produced a valid JPEG. Exact
// dimensions depend on the 10% padding clamp, so just assert
// sanity bounds rather than pinning numbers (padding math can
// legitimately drift if we tweak the heuristic later).
let img = image::load_from_memory(&bytes).expect("decode crop");
let (w, h) = (img.width(), img.height());
assert!((80..=200).contains(&w), "unexpected crop width: {w}");
assert!((80..=200).contains(&h), "unexpected crop height: {h}");
}
#[test]
fn crop_padding_clamps_to_image_bounds() {
// A bbox right at the corner should pad inward as far as it can,
// never outside the image — otherwise we'd pass invalid coords
// to the embedding service.
let f = write_solid_image(100, 100);
let bytes = crop_image_to_bbox(f.path(), 0.9, 0.9, 0.1, 0.1).expect("corner crop");
let img = image::load_from_memory(&bytes).expect("decode corner crop");
// Padded crop must fit within the source's 100x100.
assert!(img.width() <= 100);
assert!(img.height() <= 100);
assert!(img.width() > 0 && img.height() > 0);
}
}