Face Recognition / People Integration #61

Merged
cameron merged 23 commits from feature/face-recog-phase3-file-watch into master 2026-04-30 17:22:09 +00:00
Showing only changes of commit f985a0d658 - Show all commits

View File

@@ -1516,12 +1516,14 @@ async fn create_person_handler<D: FaceDao>(
Ok(p) => HttpResponse::Created().json(p), Ok(p) => HttpResponse::Created().json(p),
Err(e) => { Err(e) => {
// SQLite UNIQUE(name COLLATE NOCASE) → 409 Conflict so the UI // SQLite UNIQUE(name COLLATE NOCASE) → 409 Conflict so the UI
// can show "name already exists" without parsing. // can show "name already exists" without parsing. Use {:#} to
let msg = format!("{}", e); // include the source chain — anyhow's plain Display only shows
if msg.to_lowercase().contains("unique") { // the outermost context ("insert person ...") which hides the
// diesel "UNIQUE constraint failed" we're keying on.
if is_unique_violation(&e) {
HttpResponse::Conflict().body("person name already exists") HttpResponse::Conflict().body("person name already exists")
} else { } else {
HttpResponse::InternalServerError().body(msg) HttpResponse::InternalServerError().body(format!("{:#}", e))
} }
} }
} }
@@ -1559,11 +1561,10 @@ async fn update_person_handler<D: FaceDao>(
match dao.update_person(&span_context, path.into_inner(), &body) { match dao.update_person(&span_context, path.into_inner(), &body) {
Ok(p) => HttpResponse::Ok().json(p), Ok(p) => HttpResponse::Ok().json(p),
Err(e) => { Err(e) => {
let msg = format!("{}", e); if is_unique_violation(&e) {
if msg.to_lowercase().contains("unique") {
HttpResponse::Conflict().body("person name already exists") HttpResponse::Conflict().body("person name already exists")
} else { } else {
HttpResponse::InternalServerError().body(msg) HttpResponse::InternalServerError().body(format!("{:#}", e))
} }
} }
} }
@@ -1605,7 +1606,7 @@ async fn merge_persons_handler<D: FaceDao>(
match dao.merge_persons(&span_context, src, body.into) { match dao.merge_persons(&span_context, src, body.into) {
Ok(p) => HttpResponse::Ok().json(p), Ok(p) => HttpResponse::Ok().json(p),
Err(e) => { Err(e) => {
let msg = format!("{}", e); let msg = format!("{:#}", e);
if msg.contains("itself") { if msg.contains("itself") {
HttpResponse::BadRequest().body(msg) HttpResponse::BadRequest().body(msg)
} else { } else {
@@ -1681,6 +1682,27 @@ fn crop_image_to_bbox(
Ok(out.into_inner()) Ok(out.into_inner())
} }
/// Returns true if `err` (or anything in its source chain) is a SQLite
/// `UNIQUE constraint failed`. Walks the chain so callers don't have to
/// know the wrapping order — anyhow `with_context` plus diesel's own
/// error layering buries the database error two levels deep.
///
/// String matching on `format!("{:#}", e)` would also work but is
/// fragile (locale-dependent SQLite messages, false positives like
/// "uniquely identifies"). Downcasting to the actual diesel kind is
/// the contract-stable check.
fn is_unique_violation(err: &anyhow::Error) -> bool {
use diesel::result::{DatabaseErrorKind, Error as DieselError};
err.chain().any(|cause| {
cause.downcast_ref::<DieselError>().is_some_and(|de| {
matches!(
de,
DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)
)
})
})
}
// ── Tests ─────────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -1696,6 +1718,61 @@ mod tests {
opentelemetry::Context::current() opentelemetry::Context::current()
} }
#[test]
fn is_unique_violation_walks_chain() {
// The bug we hit in manual testing: anyhow's plain Display only
// shows the outermost context ("insert person Cameron"), so a
// naive `format!("{}", e).contains("unique")` check misses the
// diesel UNIQUE error nested below. Downcasting the source chain
// is the stable contract.
let mut dao = fresh_dao();
let _ = dao
.create_person(
&ctx(),
&CreatePersonReq {
name: "Cameron".into(),
notes: None,
entity_id: None,
},
false,
)
.expect("first insert");
let dup_err = dao
.create_person(
&ctx(),
&CreatePersonReq {
name: "Cameron".into(),
notes: None,
entity_id: None,
},
false,
)
.expect_err("second insert must fail");
// Plain Display hides the UNIQUE — that's the bug we're guarding
// against. We don't assert a specific outer message; we just
// confirm string-matching at the top level is unreliable.
let plain = format!("{}", dup_err);
assert!(
!plain.to_lowercase().contains("unique"),
"if Display starts surfacing UNIQUE we can drop the helper, but \
today it doesn't and the handler must downcast"
);
// Alt-Display walks the chain — useful for debug body content too.
let chained = format!("{:#}", dup_err);
assert!(
chained.to_uppercase().contains("UNIQUE"),
"chained display must surface the diesel error: {chained}"
);
// The contract-stable check the handler actually uses.
assert!(
is_unique_violation(&dup_err),
"is_unique_violation must downcast into the diesel chain"
);
}
#[test] #[test]
fn person_crud_roundtrip() { fn person_crud_roundtrip() {
let mut dao = fresh_dao(); let mut dao = fresh_dao();