Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions tests/http_api/communities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,265 @@ fn the_cli_derives_incrementally_and_dry_run_writes_nothing() {
"the repaired artifact serves again: {page}"
);
}

// --- community_hits: untested refusal/degradation branches -----------------

/// The artifact's own manifest builder, `search_refuses_without_an_
/// artifact_and_verdicts_staleness_with_one`'s setup factored out so
/// the branches below can each mutate one field of an otherwise valid
/// artifact.
fn seed_manifest_artifact(server: &Server, derived: &str, manifest: &Value) {
server.ok("PUT", &format!("/contexts/{derived}"), None);
server.ok(
"POST",
&format!("/contexts/{derived}/sources"),
Some(json!({"passages": {
"community:L0-0": "夏目漱石と明治の文学者たちの交流についての要約。",
"communities:manifest": manifest.to_string(),
}})),
);
server.ok(
"POST",
&format!("/contexts/{derived}/associations"),
Some(json!([
{"subject": "community:L0-0", "label": "contains", "object": "a1", "weight": 6.0},
{"subject": "community:L0-0", "label": "contains", "object": "a2", "weight": 4.0},
])),
);
}

/// A `communities:manifest` record that fails to parse as JSON — the
/// artifact exists, but its identity record is corrupt, distinct from
/// no artifact at all (which answers "build one", not a 409).
#[test]
fn search_reports_conflict_when_the_manifest_record_does_not_parse() {
let server = Server::start("communities-manifest-corrupt");
seed_two_cliques(&server, "sci");
server.ok("PUT", "/contexts/sci::communities", None);
server.ok(
"POST",
"/contexts/sci::communities/sources",
Some(json!({"passages": {
"communities:manifest": "{not valid json",
}})),
);

let (status, refused) = server.call(
"POST",
"/contexts/sci/communities/search",
Some(json!({"query": "夏目漱石"})),
);
assert_eq!(status, 409, "{refused}");
assert_eq!(refused["code"], json!("conflict"), "{refused}");
assert!(
refused["error"]
.as_str()
.unwrap()
.contains("does not parse"),
"{refused}"
);
}

/// A manifest whose `source_context` names a DIFFERENT context than
/// the one the search was made against — the artifact answers for
/// somebody else's graph, so serving it would silently mislabel it.
#[test]
fn search_reports_conflict_when_the_manifest_names_a_different_source_context() {
let server = Server::start("communities-manifest-mismatch");
seed_two_cliques(&server, "sci");
let revision = server.ok("GET", "/contexts/sci", None)["revision"].clone();
let manifest = json!({
"taguru_communities": 1,
"algorithm": "louvain-cc/1",
"source_context": "elsewhere",
"revision": revision,
"levels": 1,
"communities": [
{"id": "L0-0", "level": 0, "fingerprint": "00aa00aa00aa00aa", "concept_count": 4},
],
});
seed_manifest_artifact(&server, "sci::communities", &manifest);

let (status, refused) = server.call(
"POST",
"/contexts/sci/communities/search",
Some(json!({"query": "夏目漱石"})),
);
assert_eq!(status, 409, "{refused}");
assert_eq!(refused["code"], json!("conflict"), "{refused}");
let message = refused["error"].as_str().unwrap();
assert!(message.contains("elsewhere"), "{message}");
assert!(message.contains("sci"), "{message}");
}

/// `check_derived_scope`'s Forbidden: the auth middleware already
/// cleared the PATH context ('sci'), but the DERIVED artifact context
/// ('sci::communities') is a second read target named in the body, and
/// a scoped key without a grant on it must be refused just as it would
/// be for the path context — otherwise a scoped key could read any
/// context by aiming a search's `derived` field at it.
#[test]
fn search_reports_forbidden_when_the_scoped_key_has_no_grant_on_the_derived_context() {
let server = Server::start_with_env(
"communities-derived-scope",
&[
("TAGURU_API_TOKENS", "boss:atok,reader:rtok"),
(
"TAGURU_KEY_SCOPES",
r#"{"reader": {"role": "read", "contexts": ["sci"]}}"#,
),
],
);
let admin = |method: &str, path: &str, body: Option<Value>| {
server.call_with_token(method, path, body, Some("atok"))
};
admin("PUT", "/contexts/sci", None);
// The same 4-clique graph `seed_two_cliques` seeds, over an
// authenticated admin token so the derived-scope grant below can
// be scoped to a real, non-empty source graph.
let members: Vec<String> = (1..=4).map(|index| format!("a{index}")).collect();
let mut ops = Vec::new();
for (index, subject) in members.iter().enumerate() {
for object in &members[index + 1..] {
ops.push(json!({"subject": subject, "label": "近い", "object": object, "weight": 2.0}));
}
}
admin(
"POST",
"/contexts/sci/associations",
Some(Value::Array(ops)),
);
let revision = admin("GET", "/contexts/sci", None).1["revision"].clone();
let manifest = json!({
"taguru_communities": 1,
"algorithm": "louvain-cc/1",
"source_context": "sci",
"revision": revision,
"levels": 1,
"communities": [
{"id": "L0-0", "level": 0, "fingerprint": "00aa00aa00aa00aa", "concept_count": 4},
],
});
admin("PUT", "/contexts/sci::communities", None);
admin(
"POST",
"/contexts/sci::communities/sources",
Some(json!({"passages": {
"community:L0-0": "夏目漱石と明治の文学者たちの交流についての要約。",
"communities:manifest": manifest.to_string(),
}})),
);
admin(
"POST",
"/contexts/sci::communities/associations",
Some(json!([
{"subject": "community:L0-0", "label": "contains", "object": "a1", "weight": 6.0},
])),
);

let (status, refused) = server.call_with_token(
"POST",
"/contexts/sci/communities/search",
Some(json!({"query": "夏目漱石"})),
Some("rtok"),
);
assert_eq!(status, 403, "{refused}");
assert_eq!(refused["code"], json!("forbidden"), "{refused}");
let message = refused["error"].as_str().unwrap();
assert!(message.contains("sci::communities"), "{message}");
}

/// `MEMBERS_PER_HIT` (12): a community's `contains` membership beyond
/// the cap is truncated, `members_truncated` says so rather than
/// silently dropping the tail — `community_hits` reads membership
/// straight off the artifact's own graph, so this needs no community
/// DETECTION run at all, just `contains` edges asserted directly.
#[test]
fn search_truncates_membership_past_members_per_hit_and_flags_it() {
let server = Server::start("communities-members-cap");
seed_two_cliques(&server, "sci");
let revision = server.ok("GET", "/contexts/sci", None)["revision"].clone();
let manifest = json!({
"taguru_communities": 1,
"algorithm": "louvain-cc/1",
"source_context": "sci",
"revision": revision,
"levels": 1,
"communities": [
{"id": "L0-0", "level": 0, "fingerprint": "00aa00aa00aa00aa", "concept_count": 13},
],
});
server.ok("PUT", "/contexts/sci::communities", None);
server.ok(
"POST",
"/contexts/sci::communities/sources",
Some(json!({"passages": {
"community:L0-0": "夏目漱石と明治の文学者たちの交流についての要約。",
"communities:manifest": manifest.to_string(),
}})),
);
// 13 members, strongest first by weight — one past MEMBERS_PER_HIT.
let members: Vec<Value> = (0..13)
.map(|i| {
json!({
"subject": "community:L0-0", "label": "contains",
"object": format!("m{i:02}"), "weight": (13 - i) as f64,
})
})
.collect();
server.ok(
"POST",
"/contexts/sci::communities/associations",
Some(Value::Array(members)),
);

let page = server.ok(
"POST",
"/contexts/sci/communities/search",
Some(json!({"query": "夏目漱石"})),
);
let hit = &page["hits"][0];
assert_eq!(hit["members_truncated"], json!(true), "{page}");
let served = hit["members"].as_array().unwrap();
assert_eq!(served.len(), 12, "{page}");
assert_eq!(served[0]["name"], json!("m00"), "strongest first: {page}");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/// A community summary is searchable (its passage and `contains`
/// membership exist in the artifact) but the manifest's `communities`
/// array does not list it — a torn artifact, one write behind the
/// other. `level`/`parent`/`concept_count` all come from the manifest
/// fact lookup (`facts.get(summary.id)`), so absence there must
/// degrade those three fields to null rather than panic or fabricate
/// zeros.
#[test]
fn search_omits_manifest_facts_for_a_community_the_manifest_does_not_list() {
let server = Server::start("communities-manifest-torn");
seed_two_cliques(&server, "sci");
let revision = server.ok("GET", "/contexts/sci", None)["revision"].clone();
// The manifest's own `communities` array is empty — L0-0 is
// searchable (passage + contains edges below) but unlisted.
let manifest = json!({
"taguru_communities": 1,
"algorithm": "louvain-cc/1",
"source_context": "sci",
"revision": revision,
"levels": 1,
"communities": [],
});
seed_manifest_artifact(&server, "sci::communities", &manifest);

let page = server.ok(
"POST",
"/contexts/sci/communities/search",
Some(json!({"query": "夏目漱石"})),
);
let hit = &page["hits"][0];
assert_eq!(hit["community"], json!("L0-0"), "{page}");
assert!(hit["level"].is_null(), "{page}");
assert!(hit["parent"].is_null(), "{page}");
assert!(hit["concept_count"].is_null(), "{page}");
// Membership itself is unaffected — it comes straight off the
// graph, not the manifest.
assert_eq!(hit["members"].as_array().unwrap().len(), 2, "{page}");
}
1 change: 1 addition & 0 deletions tests/http_api/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod offline_import;
mod passages;
mod promote;
mod quotas;
mod recall;
mod reload;
mod remote_compact;
mod remote_export;
Expand Down
112 changes: 112 additions & 0 deletions tests/http_api/recall.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! `POST /recall` (`cross_recall`): fan-out over several contexts at
//! once. Two untested branches from issue #627's audit: `cross_targets`'s
//! upfront existence check aborts naming the LIST-first missing
//! target when several are missing at once (`src/api/recall.rs`'s own
//! `cross_matches_names_a_target_that_does_not_exist` unit test only
//! ever names ONE missing target, via `cross_matches` called
//! directly — it never pins which one is reported when several are
//! missing, nor exercises the HTTP-level `cross_targets` check that
//! runs before `cross_matches` is ever reached), and the mid-loop
//! memory-bound pool cut (`pool.len() >= limit * 2`) still yields the
//! exact global top-`limit` even when it fires before every target has
//! been folded in.

use serde_json::json;

use crate::support::*;

/// `cross_targets`'s own existence check (`src/api/recall.rs`) walks
/// `contexts` with `.iter().find(...)` — a plain, ordered scan, not a
/// concurrent fan-out — so when several named contexts are missing at
/// once, the refusal must always name the FIRST one by request-list
/// order, deterministically. Deleting a context sweeps it out of any
/// group's membership too (`sweep_context_from_groups`, `delete`'s own
/// doc), so a group can never durably name a member that has already
/// vanished; the ordering guarantee this pins is `contexts`'s own
/// list, not a race.
#[test]
fn cross_recall_aborts_naming_the_first_missing_context_by_list_order() {
let server = Server::start("recall-fanout-abort");
server.ok("PUT", "/contexts/stays", None);
server.ok(
"POST",
"/contexts/stays/associations",
Some(json!([
{"subject": "蔵", "label": "産地", "object": "灘", "weight": 1.0, "source": "a.md"}
])),
);

let (status, refused) = server.call(
"POST",
"/recall",
Some(json!({"contexts": ["absent-a", "absent-b", "stays"], "cue": "蔵"})),
);
assert_eq!(status, 404, "{refused}");
assert_eq!(refused["code"], json!("no_context"), "{refused}");
let message = refused["error"].as_str().unwrap();
assert!(
message.contains("absent-a"),
"must name the list-first target, not the second: {message}"
);
assert!(
!message.contains("absent-b"),
"must not name the second target instead: {message}"
);
}

/// The mid-loop memory-bound cut (`src/api/recall.rs`: once the
/// pooled matches reach `limit * 2`, the pool is cut down to the
/// current top-`limit` before the next target's results are even
/// folded in — a streaming top-K, not a single sort at the end).
/// `limit: 5` makes the cut fire after the second of three targets;
/// the third target's weights are deliberately the HIGHEST of all
/// three, so a correct result must show the mid-loop cut did not
/// permanently lock in the first two targets' (weaker) candidates —
/// the final page is exactly that third target's top 5, and `total`
/// still counts every match from all three, cut or not.
#[test]
fn cross_recall_mid_loop_pool_cut_still_yields_the_exact_global_top_limit() {
let server = Server::start("recall-pool-cut");
// r1: mid-strength, r2: weak, r3: strongest — target order is the
// request's own `contexts` list order, not weight order.
let waves: [(&str, &[f64]); 3] = [
("r1", &[10.0, 9.0, 8.0, 7.0, 6.0, 5.0]),
("r2", &[4.0, 3.0, 2.0, 1.0, 0.5, 0.4]),
("r3", &[20.0, 19.0, 18.0, 17.0, 16.0, 15.0]),
];
for (name, weights) in waves {
server.ok("PUT", &format!("/contexts/{name}"), None);
let ops: Vec<serde_json::Value> = weights
.iter()
.enumerate()
.map(|(i, weight)| {
json!({
"subject": "蔵", "label": "産地", "object": format!("{name}-o{i}"),
"weight": weight, "source": "a.md",
})
})
.collect();
server.ok(
"POST",
&format!("/contexts/{name}/associations"),
Some(serde_json::Value::Array(ops)),
);
}

let page = server.ok(
"POST",
"/recall",
Some(json!({"contexts": ["r1", "r2", "r3"], "cue": "蔵", "limit": 5})),
);
assert_eq!(page["total"], json!(18), "{page}");
let matches = page["matches"].as_array().unwrap();
assert_eq!(matches.len(), 5, "{page}");
for entry in matches {
assert_eq!(entry["context"], json!("r3"), "{page}");
}
let weights: Vec<f64> = matches
.iter()
.map(|entry| entry["weight"].as_f64().unwrap())
.collect();
assert_eq!(weights, vec![20.0, 19.0, 18.0, 17.0, 16.0], "{page}");
}
Loading