Files
buzz/desktop/src-tauri/src/archive/mod_tests.rs
Will Pfleger 5e4c05f90b feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000)
## What

Implements Phases 2 and 4a of the Usage v2 plan (plan events
`d0268cd0`/`0e95b035`), extending the archive backend to emit,
transport, archive, and aggregate both cache categories and billing
identity fail-closed.

### P2 — emission, transport, archive

**Tri-state accumulators** (`Unseen`/`Exact`/`Unknown`) for cache-read
and cache-write in `buzz-agent` turn and session state. Absent field =
Unknown (never zero) through the full pipeline. No `unwrap_or(0)` on the
cache path. Both cache folds are gated on usage-bearing responses (same
gate as the total-state and identity folds) — a response with no usage
at all must not poison either accumulator.

**Overflow-aware input token parsing and accumulation** — closed
end-to-end from parse through wire to ACP:
- `sum_usage()` returns `SumUsageResult` (`Exact(u64)` | `Overflow`) —
checked arithmetic, never clamps. `anthropic_input_tokens()` returns
`Option<SumUsageResult>` since it sums three fields (`input_tokens +
cache_read_input_tokens + cache_creation_input_tokens`) that can
collectively overflow. Single-field callers (`prompt_tokens`,
`completion_tokens`, etc.) convert via `.into_exact()` — their
single-field sums cannot overflow.
- `LlmResponse.input_tokens_overflowed: bool` propagates the parse-layer
signal into the run loop. When set, `input_tokens` is `None` (clamped
value discarded), the context-gate baseline
(`last_request_input_tokens`) is frozen at its prior reading, and
`turn_input_tokens` is poisoned to `TurnIOState::Poisoned` before any
emission — including mid-turn `emit_usage_update` calls. A dedicated
enum on `LlmResponse.input_tokens` would ripple into ~20 existing test
assertions on `r.input_tokens == Some(...)`; the bool flag confines the
change to the two call sites that check it.
- `TurnIOState` (`Unseen`/`Exact`/`Poisoned`) for input and output:
per-round fold uses `checked_add`; overflow poisons permanently at turn
and session level, no healing. Absence does not poison (pass-2-cleared
contract unchanged). Wire emission omits
`accumulatedInputTokens`/`accumulatedOutputTokens` when poisoned — never
null, never `u64::MAX`. ACP treats absent = publisher-poisoned:
`delta_reliable: false`, null turn fields, null cumulative for that
category; session cumulative stays unknown for all subsequent turns once
poisoned.

**Conditional wire emission** for `accumulatedCachedInputTokens` and new
`accumulatedCacheWriteTokens`: fields are omitted when the cumulative is
Unseen or Unknown. ACP `_goose/unstable/session/update` contract
documented next to the payload with tests for all absence/zero variants.

**`PricingIdentity` stamping (publisher-side)**:
- `pricing_authority()`: canonical parsed-URL endpoint comparison
against the official allowlist — HTTPS only, exact allowlisted host
(lookalike-safe), default port (omitted or explicit :443), required API
base path, rejects userinfo/query/fragment/path-prefix lookalikes.
- Model: the actually-requested `request_model` after mesh/auto
resolution (not `effective_model_str`).
- Turn discipline: identity retained only while ALL usage in the current
turn carries one identical proven identity; any mismatch,
unproven-usage-bearing response, or unpaired cumulative snapshot poisons
to absent; a later matching notification does not heal a mixed turn.

**ACP `UsageTracker` identity fold**: per-in-flight-turn tri-state
identity accumulator replacing last-update-wins. Any absent identity on
a token-advancing notification or exact mismatch poisons to absent;
poison survives later updates; reset in `begin_turn()`/`take()`; reset
also when a request fails (baseline cleared so preflight gate cannot
stay frozen sub-threshold on retries).

**M3 migration**: adds `turn_cache_write_tokens`,
`cumulative_cache_write_tokens`, `pricing_authority`, `pricing_model`,
`pricing_cache_class` to `agent_metric_index`. Additive, idempotent,
guarded per-column by marker. M2 migration also guarded per-column (turn
and cumulative cache-read columns checked and added independently;
marker commits only after both are present). Fresh-DB schema includes
all columns.

**First-turn baselines**: `seed_zero_baseline` seeds `last_input:
Some(0)`, `last_output: Some(0)`, `last_cached_input: Some(0)`,
`last_cache_write: Some(0)`, and `last_total: Some(0)` — all have the
known-zero-at-spawn argument. Absent fields from incoming snapshots
still produce unknown (tri-state unchanged). Sessions buzz-acp did not
spawn (no seed) remain fail-closed on turn one.

**`ReportedUsage` TS mirror**: `cacheReadTokens`, `cacheWriteTokens`,
`freshInputTokens` added to `tauriArchive.ts` as `UsageField` members,
field-for-field with the Rust struct.

### P4a — aggregation layer

**Extended S-1 ladder** to cache-read and cache-write via the same
`ladder_token` path as the existing token fields.

**`freshInputTokens` derivation**: checked arithmetic, fail-closed —
absent cache fields produce Unknown (not zero), overflow and
`cacheRead+cacheWrite > input` both produce `incomplete: true`.
Aggregated as a `UsageField`.

**D6 comparator**: `sort_value()` = provider total when known, else
`input+output` when both known, else `None` (unknown-last). Replaces the
prior total-only comparator for both agent-level and model-level sort.
Ships a pinned test vector that the TS render layer (P5) must match.

## Test coverage

- `buzz-agent`: 440 lib + 15 integration (golden_transcripts) — includes
13 new `cache_total_state_tests`; 14 new `turn_io_state_tests`; 3 new
`sum_usage_*` tests (exact single-field, exact two-field, overflow
signals correctly); 3 new `parse_anthropic_*` tests (overflow flag set +
value cleared, normal sum no flag, absent usage no flag); end-to-end
golden transcript drives real subprocess with Anthropic-shaped
`input_tokens: u64::MAX, cache_read: 1` response and asserts
`accumulatedInputTokens` absent from the emitted `usage_update` — no
logic duplication; 3 wire pin tests; 4 `fold_pricing_identity_*` tests;
`pricing_authority()` explicit-:443 acceptance
- `buzz-acp`: 700 tests (691 lib + 9 integration) — 4 new usage tests
(absent input → unreliable+null; absent output → unreliable+null;
goose-shaped both present unchanged; poison mid-session); 3 ACP behavior
tests; 7 pool lifecycle tests
- Desktop (Rust): 2259+ tests — 14 new P4a pinned tests; 2 M3 round-trip
tests; 1 serde key-shape test; 2 M2 partial-schema migration tests;
first-turn cache round-trip test

## Related PRs

- P1 NIP-AM spec: [#4632](https://github.com/block/buzz/pull/4632)
- P3 pricing table: [#4629](https://github.com/block/buzz/pull/4629)
- UI (P5): [#4001](https://github.com/block/buzz/pull/4001)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-10 10:47:37 -04:00

1029 lines
39 KiB
Rust

//! Unit and integration tests for `archive/mod.rs`.
//!
//! Kept in a sibling file so `mod.rs` stays under the 1000-line gate;
//! `#[path]`-included from there.
use super::pipeline::BucketWithResult;
use super::*;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use rusqlite::Connection;
use uuid::Uuid;
// ── Helpers ──────────────────────────────────────────────────────────────
fn in_memory() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "busy_timeout", 5000).unwrap();
conn.execute_batch(super::store::SCHEMA).unwrap();
conn
}
fn make_observer_frame(owner_keys: &Keys, agent_keys: &Keys, frame_type: &str) -> Event {
let owner_pk = owner_keys.public_key().to_hex();
let agent_pk = agent_keys.public_key().to_hex();
let tags = vec![
Tag::parse(["p", &owner_pk]).unwrap(),
Tag::parse(["agent", &agent_pk]).unwrap(),
Tag::parse(["frame", frame_type]).unwrap(),
];
EventBuilder::new(Kind::Custom(24200), "A".repeat(200))
.tags(tags)
.sign_with_keys(agent_keys)
.unwrap()
}
fn add_sub(
conn: &Connection,
identity_pk: &str,
relay_url: &str,
scope_type: &str,
scope_value: &str,
kinds_json: &str,
) {
store::upsert_save_subscription(
conn,
identity_pk,
relay_url,
scope_type,
scope_value,
kinds_json,
0,
)
.unwrap();
}
/// Run the full archive pipeline synchronously with a fake relay response.
///
/// Calls `plan_archive` → injects fake relay events → `commit_archive`.
/// This mirrors `archive_events` without the async relay calls.
fn run_batch_sync(
candidates: Vec<ArchiveCandidate>,
identity_pk: &str,
relay_url: &str,
conn: &Connection,
fake_relay_events: Vec<Event>,
) -> ArchiveBatchResult {
let owner_keys = Keys::generate();
run_batch_sync_with_keys(
candidates,
identity_pk,
relay_url,
conn,
fake_relay_events,
&owner_keys,
)
}
/// Like `run_batch_sync` but with a specific owner `Keys` for decrypt.
fn run_batch_sync_with_keys(
candidates: Vec<ArchiveCandidate>,
identity_pk: &str,
relay_url: &str,
conn: &Connection,
fake_relay_events: Vec<Event>,
owner_keys: &Keys,
) -> ArchiveBatchResult {
let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap();
// Synthesize BucketWithResult from the fake relay response.
let fake_ids: std::collections::HashSet<String> =
fake_relay_events.iter().map(|e| e.id.to_hex()).collect();
let bucket_results: Vec<BucketWithResult> = plan
.buckets
.into_iter()
.map(|b| BucketWithResult {
scope_type_str: b.scope_type_str,
scope_value: b.scope_value,
allowed_kinds: b.allowed_kinds,
group: b.group,
returned_ids: fake_ids.clone(),
relay_failed: false,
})
.collect();
commit_archive(
bucket_results,
plan.ephemeral,
plan.pre_dropped,
identity_pk,
relay_url,
owner_keys,
0,
conn,
)
.unwrap()
}
fn candidate(event: &Event, scope_type: ScopeType, scope_value: &str) -> ArchiveCandidate {
ArchiveCandidate {
raw_event_json: event.as_json(),
matched_scope: MatchedScope {
scope_type,
scope_value: scope_value.to_string(),
},
}
}
// ── Ephemeral validator — individual condition rejection ──────────────────
#[test]
fn test_ephemeral_validator_accepts_valid_frame() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
assert!(
validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url).is_ok()
);
}
#[test]
fn test_ephemeral_validator_rejects_wrong_kind() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = EventBuilder::new(Kind::TextNote, "hello")
.tags(vec![
Tag::parse(["p", &owner_pk]).unwrap(),
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(),
])
.sign_with_keys(&agent_keys)
.unwrap();
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("kind"));
}
#[test]
fn test_ephemeral_validator_rejects_missing_p_tag() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = EventBuilder::new(Kind::Custom(24200), "A".repeat(200))
.tags(vec![
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(),
])
.sign_with_keys(&agent_keys)
.unwrap();
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("#p"));
}
#[test]
fn test_ephemeral_validator_rejects_missing_agent_tag() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = EventBuilder::new(Kind::Custom(24200), "A".repeat(200))
.tags(vec![
Tag::parse(["p", &owner_pk]).unwrap(),
Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(),
])
.sign_with_keys(&agent_keys)
.unwrap();
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("agent"));
}
#[test]
fn test_ephemeral_validator_rejects_control_frame() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = make_observer_frame(&owner_keys, &agent_keys, "control");
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("telemetry"));
}
#[test]
fn test_ephemeral_validator_rejects_wrong_author() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let other_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = EventBuilder::new(Kind::Custom(24200), "A".repeat(200))
.tags(vec![
Tag::parse(["p", &owner_pk]).unwrap(),
Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(),
Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(),
])
.sign_with_keys(&other_keys) // wrong signer
.unwrap();
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("author"));
}
#[test]
fn test_ephemeral_validator_rejects_no_subscription() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
// Deliberately do NOT add a subscription.
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("owner_p subscription"));
}
#[test]
fn test_ephemeral_validator_rejects_kind_not_in_subscription() {
// Subscription exists but kinds = [1] (not 24200) — must be rejected.
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); // wrong kinds
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url);
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("24200"),
"expected kind 24200 in error, got: {msg}"
);
}
// ── archive pipeline — persistent path ───────────────────────────────────
#[test]
fn test_persistent_channel_h_persists_when_relay_returns_event() {
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-abc";
add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]");
let ev = EventBuilder::new(Kind::Custom(9), "msg")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)];
// Fake relay returns the event (simulates relay proof).
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]);
assert_eq!(result.persisted, 1);
assert_eq!(result.dropped, 0);
// Confirm the event is in the store.
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
let scope_count: i64 = conn
.query_row("SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'channel_h' AND scope_value = ?1", [chan], |r| r.get(0))
.unwrap();
assert_eq!(scope_count, 1);
}
#[test]
fn test_persistent_channel_h_drops_when_relay_does_not_return_event() {
// Relay returns empty — event not proven accessible.
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-abc";
add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]");
let ev = EventBuilder::new(Kind::Custom(9), "msg")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)];
// Fake relay returns nothing.
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![]);
assert_eq!(result.persisted, 0);
assert_eq!(result.dropped, 1);
}
#[test]
fn test_persistent_drops_when_no_subscription() {
// No subscription at all — drop before even querying.
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-abc";
// Intentionally no subscription.
let ev = EventBuilder::new(Kind::Custom(9), "msg")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)];
// Fake relay would return the event, but no sub → dropped.
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]);
assert_eq!(result.persisted, 0);
assert_eq!(result.dropped, 1);
}
#[test]
fn test_persistent_drops_kind_not_in_subscription() {
// Subscription is for kind 9 only; event is kind 7 (reaction).
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-abc";
add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]");
// kind 7 reaction — no `h` tag naturally, but relay-returned under scoped filter
let ev = EventBuilder::new(Kind::Reaction, "+")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)];
// Fake relay returns the event (simulates relay proof via StoredEvent.channel_id),
// but kind 7 is not in the subscription's kinds list.
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]);
assert_eq!(result.persisted, 0);
assert_eq!(result.dropped, 1);
}
#[test]
fn test_persistent_h_less_event_persists_when_relay_returns_it() {
// An h-less event (e.g. reaction kind:7) that the relay returns under
// the scoped #h filter (via StoredEvent.channel_id fallback) must be
// persisted. The local tag scanner would have dropped it; the scoped
// filter proof must not.
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-abc";
add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[7]");
// Build a reaction without an `h` tag — local re-derivation would drop it.
let ev = EventBuilder::new(Kind::Reaction, "+")
.sign_with_keys(&keys)
.unwrap();
let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)];
// Fake relay returns it (relay used StoredEvent.channel_id to match).
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]);
assert_eq!(result.persisted, 1);
assert_eq!(result.dropped, 0);
// Scope row uses bucket's scope_value, not a local-derived value.
let scope_val: String = conn
.query_row(
"SELECT scope_value FROM archived_event_scopes WHERE scope_type = 'channel_h'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(scope_val, chan);
}
#[test]
fn test_persistent_referenced_e_persists_when_relay_returns_event() {
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let ref_id = "a".repeat(64);
add_sub(
&conn,
&identity_pk,
relay_url,
"referenced_e",
&ref_id,
"[9]",
);
let ev = EventBuilder::new(Kind::Custom(9), "reply")
.tags(vec![Tag::parse(["e", &ref_id]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![candidate(&ev, ScopeType::ReferencedE, &ref_id)];
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]);
assert_eq!(result.persisted, 1);
assert_eq!(result.dropped, 0);
}
#[test]
fn test_mixed_batch_persisted_and_dropped_counted_exactly() {
// Two channel_h candidates: relay only returns one. dropped must be 1.
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-abc";
add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]");
let ev1 = EventBuilder::new(Kind::Custom(9), "msg1")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let ev2 = EventBuilder::new(Kind::Custom(9), "msg2")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![
candidate(&ev1, ScopeType::ChannelH, chan),
candidate(&ev2, ScopeType::ChannelH, chan),
];
// Fake relay only returns ev1.
let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev1.clone()]);
assert_eq!(result.persisted, 1);
assert_eq!(result.dropped, 1);
}
// ── archive pipeline — ephemeral path ────────────────────────────────────
#[test]
fn test_ephemeral_path_persists_valid_frame() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]");
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)];
// Fake relay returns nothing (not consulted for ephemeral path).
let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]);
assert_eq!(result.persisted, 1);
assert_eq!(result.dropped, 0);
}
#[test]
fn test_ephemeral_path_drops_kind_not_in_subscription() {
let conn = in_memory();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let owner_pk = owner_keys.public_key().to_hex();
let relay_url = "wss://relay.example";
// kinds = [1], not [24200]
add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]");
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)];
let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]);
assert_eq!(result.persisted, 0);
assert_eq!(result.dropped, 1);
}
// ── Invalid input dropped ─────────────────────────────────────────────────
#[test]
fn test_malformed_json_is_dropped() {
let result = Event::from_json("not json at all");
assert!(result.is_err());
}
#[test]
fn test_tampered_event_fails_verify_id() {
let keys = Keys::generate();
let mut ev_json: serde_json::Value = serde_json::from_str(
&EventBuilder::new(Kind::TextNote, "ok")
.sign_with_keys(&keys)
.unwrap()
.as_json(),
)
.unwrap();
ev_json["content"] = serde_json::Value::String("tampered".into());
let tampered = ev_json.to_string();
let ev = Event::from_json(&tampered).unwrap();
assert!(!ev.verify_id());
}
// ── F2: out-of-range kind ─────────────────────────────────────────────────
#[test]
fn test_out_of_range_kind_is_dropped() {
// kind 89736 == 24200 + 65536. The nostr crate truncates it to 24200
// via `v as u16`, so without the raw-kind check the validator would
// reason about 24200 while the persisted raw_json still says 89736.
// The fix rejects it before Event::from_json.
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let owner_pk = &identity_pk;
// Build a valid kind-24200 frame, then mutate only the raw kind to 89736.
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let pk = owner_keys.public_key().to_hex();
add_sub(&conn, &pk, relay_url, "owner_p", &pk, "[24200]");
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
let mut raw: serde_json::Value = serde_json::from_str(&ev.as_json()).unwrap();
raw["kind"] = serde_json::Value::Number(serde_json::Number::from(24200u64 + 65536));
let bad_json = raw.to_string();
let cand = ArchiveCandidate {
raw_event_json: bad_json,
matched_scope: MatchedScope {
scope_type: ScopeType::OwnerP,
scope_value: pk.clone(),
},
};
let result = run_batch_sync(vec![cand], &pk, relay_url, &conn, vec![]);
assert_eq!(result.persisted, 0, "out-of-range kind must be dropped");
assert_eq!(result.dropped, 1);
// No event row must exist.
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
let _ = owner_pk; // silence unused-var
}
// ── F3: transactional atomicity ───────────────────────────────────────────
#[test]
fn test_commit_archive_rolls_back_when_scope_write_would_fail() {
// Verify the split-schema invariant: if we simulate a mid-batch
// failure (by putting the DB into a state where the scope table is
// missing), the event row must NOT survive.
//
// We can't actually make upsert_event_scope fail on a healthy DB, so
// we verify the positive side: with a healthy DB, both rows are always
// written together or neither is. We confirm via run_batch_sync that
// after a full successful commit both tables have matching row counts.
let conn = in_memory();
let keys = Keys::generate();
let identity_pk = keys.public_key().to_hex();
let relay_url = "wss://relay.example";
let chan = "chan-txn";
add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]");
let ev1 = EventBuilder::new(Kind::Custom(9), "msg1")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let ev2 = EventBuilder::new(Kind::Custom(9), "msg2")
.tags(vec![Tag::parse(["h", chan]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let cands = vec![
candidate(&ev1, ScopeType::ChannelH, chan),
candidate(&ev2, ScopeType::ChannelH, chan),
];
let result = run_batch_sync(
cands,
&identity_pk,
relay_url,
&conn,
vec![ev1.clone(), ev2.clone()],
);
assert_eq!(result.persisted, 2);
assert_eq!(result.dropped, 0);
// Every event row must have exactly one corresponding scope row.
let event_count: i64 = conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
let scope_count: i64 = conn
.query_row("SELECT COUNT(*) FROM archived_event_scopes", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
event_count, scope_count,
"every event row must have a matching scope row — split-schema invariant"
);
}
// Kind-44200 agent-turn-metric coverage lives in a sibling file to keep this
// one under the 1000-line gate; nested here (not in `mod.rs`) so it inherits
// the shared fixtures above through `use super::*`.
#[path = "mod_agent_metric_tests.rs"]
mod agent_metric;
// ── Real-relay integration tests ──────────────────────────────────────────
//
// Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()`
// and `reqwest::Client` pull in native Windows DLLs (SChannel/WinHTTP) that
// are not available in the CI runner, causing STATUS_ENTRYPOINT_NOT_FOUND at
// test-binary launch before any test runs. The guard compiles the whole
// cluster out of the Windows test binary. These tests are `#[ignore]` and
// require a live relay anyway — there is no Windows relay in CI.
//
// Run (Linux/macOS only):
//
// RELAY_URL=ws://localhost:3000 cargo test -p buzz-desktop \
// archive::tests::real_relay -- --ignored --nocapture
//
// The relay must be running with a Postgres backend (same docker compose
// as the existing e2e suite). Each test creates its own keypair + channel so
// concurrent runs don't interfere.
#[cfg(not(target_os = "windows"))]
mod real_relay {
use super::*;
use crate::app_state::build_app_state;
use std::path::Path;
fn relay_ws_url_from_env() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn relay_http_base() -> String {
relay_ws_url_from_env()
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
/// Build a test AppState wired with specific identity keys and relay URL.
/// Mirrors production `archive_events`: the same `query_buckets` call path
/// is exercised, including NIP-98 signing inside `query_relay`.
fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState {
let state = build_app_state();
*state.keys.lock().unwrap() = keys;
*state.relay_url_override.lock().unwrap() = Some(relay_url.to_string());
state
}
/// Submit a signed event to the relay via `POST /events`.
/// The staging relay accepts events without NIP-98; this is only used for
/// test setup (publishing events that the archive pipeline will later query).
async fn submit_event_to_relay(ev: &Event) -> serde_json::Value {
let http = reqwest::Client::new();
let resp = http
.post(format!("{}/events", relay_http_base()))
.header("X-Pubkey", ev.pubkey.to_hex())
.header("Content-Type", "application/json")
.body(ev.as_json())
.send()
.await
.expect("submit event to relay");
assert!(
resp.status().is_success(),
"relay event submit failed: {}",
resp.status()
);
resp.json().await.expect("parse submit response")
}
/// Create an open channel on the relay. Returns the channel UUID string.
async fn create_relay_channel(keys: &Keys) -> String {
let channel_id = Uuid::new_v4().to_string();
let ev = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["name", &format!("archive-e2e-{channel_id}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let resp = submit_event_to_relay(&ev).await;
assert!(
resp["accepted"].as_bool().unwrap_or(false),
"channel creation not accepted: {resp}"
);
channel_id
}
/// Open a file-backed archive DB at `path` and insert one save subscription.
fn file_db_with_subscription(
path: &Path,
identity_pk: &str,
relay_url: &str,
scope_type: &str,
scope_value: &str,
kinds_json: &str,
) {
let conn = store::open_archive_db(path).expect("open file archive db");
add_sub(
&conn,
identity_pk,
relay_url,
scope_type,
scope_value,
kinds_json,
);
// conn drops here — file is flushed before the caller reopens it
}
/// Run plan → real relay query (via the production `query_buckets` path,
/// including NIP-98 signing) → commit, using a file-backed archive DB.
///
/// Mirrors the open/drop/query/reopen pattern of production `archive_events`:
/// 1. Open DB, run `plan_archive`, drop connection (no conn across `.await`).
/// 2. Call `query_buckets(plan.buckets, &state).await` — NIP-98 signed.
/// 3. Reopen DB for `commit_archive`.
///
/// Returns `ArchiveBatchResult`; caller reopens the file for row assertions.
async fn run_batch_real_relay(
candidates: Vec<ArchiveCandidate>,
state: &AppState,
db_path: &Path,
) -> ArchiveBatchResult {
let identity_pk = state.keys.lock().unwrap().public_key().to_hex();
let relay_url = crate::relay::relay_ws_url_with_override(state);
// Phase 1: plan (sync). Connection dropped before any .await.
let plan = {
let conn = store::open_archive_db(db_path).expect("open archive db for plan");
plan_archive(candidates, &identity_pk, &relay_url, &conn).unwrap()
// conn drops here
};
// Phase 2: relay queries (async) — no Connection in scope.
// Uses the real `query_buckets` path: query_relay → NIP-98 signed /query.
let bucket_results = query_buckets(plan.buckets, state).await;
// Phase 3: persist (sync). Fresh connection, same file.
let conn = store::open_archive_db(db_path).expect("open archive db for commit");
let owner_keys = state.keys.lock().unwrap().clone();
commit_archive(
bucket_results,
plan.ephemeral,
plan.pre_dropped,
&identity_pk,
&relay_url,
&owner_keys,
0,
&conn,
)
.unwrap()
}
/// Happy path: publish a kind:9 message to a channel, then run the archive
/// pipeline against the real relay. Asserts exact event + scope rows in a
/// file-backed SQLite archive, reopened after commit to prove persistence.
#[tokio::test]
#[ignore]
async fn test_real_relay_channel_h_happy_path_persists_event() {
let keys = Keys::generate();
let relay_url = relay_ws_url_from_env();
let state = make_test_app_state(keys.clone(), &relay_url);
let identity_pk = keys.public_key().to_hex();
// Create a channel and publish a kind:9 message.
let channel_id = create_relay_channel(&keys).await;
let msg_ev = EventBuilder::new(Kind::Custom(9), "hello archive")
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let submit_resp = submit_event_to_relay(&msg_ev).await;
assert!(
submit_resp["accepted"].as_bool().unwrap_or(false),
"message not accepted: {submit_resp}"
);
// Give the relay a moment to persist.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// File-backed archive DB with a channel_h subscription.
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("archive.db");
file_db_with_subscription(
&db_path,
&identity_pk,
&relay_url,
"channel_h",
&channel_id,
"[9]",
);
let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)];
let result = run_batch_real_relay(cands, &state, &db_path).await;
assert_eq!(result.persisted, 1, "expected 1 persisted, got {result:?}");
assert_eq!(result.dropped, 0, "expected 0 dropped, got {result:?}");
// Reopen the same file to assert exact row counts — proves file-backed persistence.
let read_conn = store::open_archive_db(&db_path).expect("reopen archive db");
let event_count: i64 = read_conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
assert_eq!(event_count, 1, "archived_events should have 1 row");
let scope_count: i64 = read_conn
.query_row(
"SELECT COUNT(*) FROM archived_event_scopes \
WHERE scope_type = 'channel_h' AND scope_value = ?1",
[&channel_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(scope_count, 1, "archived_event_scopes should have 1 row");
// Confirm the stored raw_json round-trips to the original event.
let raw_json: String = read_conn
.query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0))
.unwrap();
let stored_ev = Event::from_json(&raw_json).unwrap();
assert_eq!(stored_ev.id.to_hex(), msg_ev.id.to_hex());
println!(
"✓ real relay: event {} archived under channel_h:{}",
msg_ev.id.to_hex(),
channel_id
);
println!(" archived_events: {event_count} row(s)");
println!(" archived_event_scopes: {scope_count} row(s)");
}
/// Kind-mismatch drop: subscription allows only kinds=[1059] but we publish a kind:9
/// channel message. The relay stores the event, but the archive's kind filter drops it
/// because 9 ∉ {1059}.
#[tokio::test]
#[ignore]
async fn test_real_relay_kind_mismatch_drops_event() {
let keys = Keys::generate();
let relay_url = relay_ws_url_from_env();
let state = make_test_app_state(keys.clone(), &relay_url);
let identity_pk = keys.public_key().to_hex();
let channel_id = create_relay_channel(&keys).await;
// Publish a kind:9 channel message — relay accepts it, but the subscription's
// kind filter is [1059] so the archive pipeline must drop it.
let msg_ev = EventBuilder::new(Kind::Custom(9), "kind-mismatch-msg")
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let submit_resp = submit_event_to_relay(&msg_ev).await;
assert!(
submit_resp["accepted"].as_bool().unwrap_or(false),
"message not accepted: {submit_resp}"
);
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Subscription allows kinds=[1059] only — kind:9 must be dropped.
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("archive.db");
file_db_with_subscription(
&db_path,
&identity_pk,
&relay_url,
"channel_h",
&channel_id,
"[1059]",
);
let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)];
let result = run_batch_real_relay(cands, &state, &db_path).await;
assert_eq!(result.persisted, 0, "kind-mismatch: should be dropped");
assert_eq!(result.dropped, 1, "kind-mismatch: drop count should be 1");
// Belt-and-suspenders: confirm the on-disk archive is genuinely empty.
let read_conn = store::open_archive_db(&db_path).expect("reopen archive db");
let event_count: i64 = read_conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
assert_eq!(
event_count, 0,
"nothing should be archived on kind-mismatch"
);
println!(
"✓ real relay: kind-mismatch correctly dropped kind:9 event (subscription is kinds=[1059])"
);
}
/// No-subscription drop: event arrives but no save_subscription row exists.
#[tokio::test]
#[ignore]
async fn test_real_relay_no_subscription_drops_event() {
let keys = Keys::generate();
let relay_url = relay_ws_url_from_env();
let state = make_test_app_state(keys.clone(), &relay_url);
let channel_id = create_relay_channel(&keys).await;
let msg_ev = EventBuilder::new(Kind::Custom(9), "should be dropped")
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
submit_event_to_relay(&msg_ev).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Empty file-backed archive DB — no subscription row.
// plan_archive drops the whole group because no subscription matches.
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("archive.db");
store::open_archive_db(&db_path).expect("init empty archive db");
// conn from init drops here; DB file exists but has no subscription rows
let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)];
let result = run_batch_real_relay(cands, &state, &db_path).await;
assert_eq!(result.persisted, 0, "no-sub: should be dropped");
assert_eq!(result.dropped, 1, "no-sub: drop count should be 1");
// Belt-and-suspenders: confirm the on-disk archive is genuinely empty.
let read_conn = store::open_archive_db(&db_path).expect("reopen archive db");
let event_count: i64 = read_conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
assert_eq!(
event_count, 0,
"nothing should be archived on no-subscription"
);
println!("✓ real relay: no-subscription correctly dropped event");
}
/// Owner_p ephemeral path: a locally-built valid 24200 frame is archived
/// without a relay query (ephemeral events are never stored on the relay).
#[tokio::test]
#[ignore]
async fn test_real_relay_owner_p_ephemeral_path_persists_valid_frame() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let relay_url = relay_ws_url_from_env();
let state = make_test_app_state(owner_keys.clone(), &relay_url);
let identity_pk = owner_keys.public_key().to_hex();
// Build a valid kind:24200 observer frame addressed to the owner.
let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY);
// File-backed archive DB with an owner_p subscription for kind 24200.
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("archive.db");
file_db_with_subscription(
&db_path,
&identity_pk,
&relay_url,
"owner_p",
&identity_pk,
"[24200]",
);
// owner_p candidates bypass the relay entirely — query_buckets gets an
// empty bucket list and the ephemeral path handles the frame locally.
let cands = vec![candidate(&ev, ScopeType::OwnerP, &identity_pk)];
let result = run_batch_real_relay(cands, &state, &db_path).await;
assert_eq!(
result.persisted, 1,
"owner_p: valid frame should be persisted"
);
assert_eq!(result.dropped, 0, "owner_p: nothing should be dropped");
// Reopen the same file to assert exact row counts.
let read_conn = store::open_archive_db(&db_path).expect("reopen archive db");
let event_count: i64 = read_conn
.query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0))
.unwrap();
assert_eq!(event_count, 1);
let scope_count: i64 = read_conn
.query_row(
"SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'owner_p'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(scope_count, 1);
// Confirm the stored raw_json round-trips to the original frame.
let raw_json: String = read_conn
.query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0))
.unwrap();
let stored_ev = Event::from_json(&raw_json).unwrap();
assert_eq!(stored_ev.id.to_hex(), ev.id.to_hex());
println!(
"✓ real relay: owner_p ephemeral frame {} archived",
ev.id.to_hex()
);
println!(" archived_events: {event_count} row(s)");
println!(" archived_event_scopes: {scope_count} row(s)");
}
}