Skip to content

Commit 69c7804

Browse files
committed
test: guard recent thread route against store scans
1 parent 1f4c0b7 commit 69c7804

1 file changed

Lines changed: 151 additions & 3 deletions

File tree

garyx-gateway/src/routes/tests.rs

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use super::*;
22
use std::collections::{BTreeSet, HashMap};
3-
use std::sync::Arc;
4-
use std::sync::atomic::{AtomicBool, Ordering};
3+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4+
use std::sync::{Arc, Mutex};
55

6+
use async_trait::async_trait;
67
use axum::body::Body;
78
use futures_util::StreamExt;
89
use garyx_bridge::MultiProviderBridge;
@@ -18,7 +19,9 @@ use garyx_models::provider::{
1819
};
1920
use garyx_models::thread_logs::{ThreadLogEvent, ThreadLogSink};
2021
use garyx_models::{RenderDelta, apply_render_delta, render_rows_digest};
21-
use garyx_router::{MessageRouter, RunTranscriptRecordDraft};
22+
use garyx_router::{
23+
InMemoryThreadStore, MessageRouter, RunTranscriptRecordDraft, ThreadStore, ThreadStoreError,
24+
};
2225
use std::path::Path;
2326
use std::process::Command;
2427
use tempfile::{TempDir, tempdir};
@@ -40,6 +43,69 @@ fn authed_request() -> axum::http::request::Builder {
4043
crate::test_support::authed_request()
4144
}
4245

46+
struct RouteStoreProbe {
47+
inner: Arc<dyn ThreadStore>,
48+
list_calls: AtomicUsize,
49+
get_calls: Mutex<Vec<String>>,
50+
}
51+
52+
impl RouteStoreProbe {
53+
fn new(inner: Arc<dyn ThreadStore>) -> Self {
54+
Self {
55+
inner,
56+
list_calls: AtomicUsize::new(0),
57+
get_calls: Mutex::new(Vec::new()),
58+
}
59+
}
60+
61+
fn reset_observations(&self) {
62+
self.list_calls.store(0, Ordering::SeqCst);
63+
self.get_calls
64+
.lock()
65+
.expect("route store probe lock")
66+
.clear();
67+
}
68+
69+
fn get_calls(&self) -> Vec<String> {
70+
self.get_calls
71+
.lock()
72+
.expect("route store probe lock")
73+
.clone()
74+
}
75+
}
76+
77+
#[async_trait]
78+
impl ThreadStore for RouteStoreProbe {
79+
async fn get(&self, thread_id: &str) -> Option<Value> {
80+
self.get_calls
81+
.lock()
82+
.expect("route store probe lock")
83+
.push(thread_id.to_owned());
84+
self.inner.get(thread_id).await
85+
}
86+
87+
async fn set(&self, thread_id: &str, data: Value) {
88+
self.inner.set(thread_id, data).await;
89+
}
90+
91+
async fn delete(&self, thread_id: &str) -> bool {
92+
self.inner.delete(thread_id).await
93+
}
94+
95+
async fn list_keys(&self, prefix: Option<&str>) -> Vec<String> {
96+
self.list_calls.fetch_add(1, Ordering::SeqCst);
97+
self.inner.list_keys(prefix).await
98+
}
99+
100+
async fn exists(&self, thread_id: &str) -> bool {
101+
self.inner.exists(thread_id).await
102+
}
103+
104+
async fn update(&self, thread_id: &str, updates: Value) -> Result<(), ThreadStoreError> {
105+
self.inner.update(thread_id, updates).await
106+
}
107+
}
108+
43109
/// Every message-ref id a wire `render_state.rows` tree references, in
44110
/// tree order — the "which messages does this frame render" oracle.
45111
fn render_state_ref_ids(render_state: &Value) -> Vec<String> {
@@ -3355,6 +3421,88 @@ async fn recent_threads_route_filters_tasks_and_preserves_default_response() {
33553421
);
33563422
}
33573423

3424+
#[tokio::test]
3425+
async fn recent_threads_tasks_exclude_never_scans_thread_store() {
3426+
let store = Arc::new(RouteStoreProbe::new(Arc::new(InMemoryThreadStore::new())));
3427+
let state = AppStateBuilder::new(test_config())
3428+
.with_thread_store(store.clone())
3429+
.build();
3430+
for (thread_id, thread_type, timestamp) in [
3431+
(
3432+
"thread::route-no-scan-task",
3433+
"task",
3434+
"2026-05-23T14:00:00Z",
3435+
),
3436+
(
3437+
"thread::route-no-scan-chat",
3438+
"chat",
3439+
"2026-05-23T13:00:00Z",
3440+
),
3441+
] {
3442+
state
3443+
.threads
3444+
.thread_store
3445+
.set(
3446+
thread_id,
3447+
json!({
3448+
"thread_id": thread_id,
3449+
"thread_kind": thread_type,
3450+
"label": format!("Title for {thread_id}"),
3451+
"updated_at": timestamp,
3452+
}),
3453+
)
3454+
.await;
3455+
state
3456+
.ops
3457+
.garyx_db
3458+
.upsert_recent_thread(RecentThreadDraft {
3459+
thread_id: thread_id.to_owned(),
3460+
title: format!("Title for {thread_id}"),
3461+
workspace_dir: Some("/workspace/test".to_owned()),
3462+
thread_type: thread_type.to_owned(),
3463+
provider_type: None,
3464+
agent_id: None,
3465+
message_count: 0,
3466+
last_message_preview: String::new(),
3467+
recent_run_id: None,
3468+
active_run_id: None,
3469+
run_state: "idle".to_owned(),
3470+
updated_at: Some(timestamp.to_owned()),
3471+
last_active_at: timestamp.to_owned(),
3472+
})
3473+
.expect("seed recent projection");
3474+
}
3475+
store.reset_observations();
3476+
let router = build_router(state);
3477+
3478+
let response = router
3479+
.oneshot(
3480+
authed_request()
3481+
.uri("/api/recent-threads?limit=10&offset=0&tasks=exclude")
3482+
.body(Body::empty())
3483+
.unwrap(),
3484+
)
3485+
.await
3486+
.unwrap();
3487+
assert_eq!(response.status(), StatusCode::OK);
3488+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
3489+
.await
3490+
.unwrap();
3491+
let payload: Value = serde_json::from_slice(&body).unwrap();
3492+
assert_eq!(payload["total"], 1);
3493+
assert_eq!(payload["threads"][0]["thread_id"], "thread::route-no-scan-chat");
3494+
assert_eq!(
3495+
store.list_calls.load(Ordering::SeqCst),
3496+
0,
3497+
"recent task filtering must never enumerate thread-store keys"
3498+
);
3499+
assert_eq!(
3500+
store.get_calls(),
3501+
vec!["thread::route-no-scan-chat"],
3502+
"task rows must be filtered by SQL before per-result runtime point reads"
3503+
);
3504+
}
3505+
33583506
#[tokio::test]
33593507
async fn recent_threads_route_removes_hidden_threads_from_projection() {
33603508
let state = AppStateBuilder::new(test_config()).build();

0 commit comments

Comments
 (0)