OXYGENHUB
◆ v0.9.9 oxygenhub / crates / hub / tests / proposals.rs
62.0 KiB · Rust · last changed A push to a landed proposal's bookmark says so; its page lists the changes since landing (closes #329); pin OxygenFlow main (oxygen's push_notes) 9 days ago source blame history raw
de505df5 Andy McCall ↶ 1
//! Milestone 0.3 end to end: a proposal from a bookmark and from a fork,
2
//! an atom-anchored comment, a seal-backed approval, requirements and a
3
//! landing from the hub.
4
5
use axum::body::Body;
6
use axum::http::{header, Request, StatusCode};
7
use http_body_util::BodyExt;
8
use oxy_core::repo::Repo;
9
use oxygenhub::config::Config;
10
use oxygenhub::db;
11
use oxygenhub::repos::RepoName;
12
use oxygenhub::Shared;
13
use std::path::Path;
14
use tower::ServiceExt;
15
16
fn hermetic() {
17
    static ONCE: std::sync::Once = std::sync::Once::new();
18
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
19
    ONCE.call_once(|| {
20
        let d = tempfile::tempdir().unwrap();
21
        std::env::set_var("OXY_HOME", d.path());
22
        DIR.set(d).ok();
23
    });
24
}
25
26
fn write(root: &Path, rel: &str, content: &[u8]) {
27
    let p = root.join(rel);
28
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
29
    std::fs::write(p, content).unwrap();
30
}
31
32
struct Hub {
33
    app: Shared,
34
    base: String,
35
    _dir: tempfile::TempDir,
36
}
37
38
async fn hub() -> Hub {
39
    hermetic();
40
    let dir = tempfile::tempdir().unwrap();
41
    let mut config = Config::load(dir.path()).unwrap();
c8684d44 Andy McCall 42
    // The first account's setup code (oxygenhub#245).
43
    config.registration.setup_code = "test-setup".into();
de505df5 Andy McCall ↶ 44
    config.auth.push_tokens = vec!["site".into()];
45
    config.registration.mode = "open".into();
46
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
47
    let port = listener.local_addr().unwrap().port();
48
    config.server.base_url = format!("http://127.0.0.1:{port}");
9a777fec Andy McCall ↶ 49
    // The stand-in webhook and assistant servers listen on 127.0.0.1 (#219).
50
    config.outbound.allow = vec!["http://127.0.0.1".into()];
de505df5 Andy McCall ↶ 51
    let app = oxygenhub::build(config).await.unwrap();
52
    let router = oxygenhub::router(app.clone());
53
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
54
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
55
}
56
57
struct Res {
58
    status: StatusCode,
59
    body: String,
60
    cookie: Option<String>,
61
}
62
63
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: Option<&str>) -> Res {
64
    let mut req = Request::builder().method(method).uri(path);
65
    if let Some(c) = cookie {
66
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
67
    }
68
    if let Some(b) = bearer {
69
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
70
    }
71
    let body = match form {
72
        Some(f) => {
73
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
74
            Body::from(f.to_string())
75
        }
76
        None => Body::empty(),
77
    };
78
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
79
    let status = res.status();
80
    let cookie = res
81
        .headers()
82
        .get(header::SET_COOKIE)
83
        .and_then(|v| v.to_str().ok())
84
        .and_then(|v| v.split(';').next())
85
        .and_then(|kv| kv.split_once('='))
86
        .map(|(_, v)| v.to_string());
87
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
88
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
89
}
90
91
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
92
    send(app, "GET", path, cookie, None, None).await
93
}
94
95
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
96
    send(app, "POST", path, cookie, Some(form), None).await
97
}
98
a1b98695 Andy McCall ↶ 99
/// POST a proposal action and follow its redirect back to the page, as a
100
/// browser does (actions redirect so a reload never repeats them).
101
async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
102
    let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
103
    if let Some(c) = cookie {
104
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
105
    }
106
    let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap();
107
    assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back");
108
    let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
109
    get(app, &to, cookie).await
110
}
111
de505df5 Andy McCall ↶ 112
async fn register(app: &Shared, user: &str) -> String {
c8684d44 Andy McCall 113
    let r = post(
114
        app,
115
        "/register",
116
        None,
117
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
118
    )
119
    .await;
de505df5 Andy McCall ↶ 120
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
121
    r.cookie.unwrap()
122
}
123
124
fn csrf_of(page: &str) -> String {
125
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
126
    page[i..].split('"').next().unwrap().to_string()
127
}
128
129
#[tokio::test(flavor = "multi_thread")]
130
async fn propose_review_seal_and_land() {
131
    let h = hub().await;
132
    let app = &h.app;
133
    let andy = register(app, "andy").await;
134
    let bob = register(app, "bob").await;
135
136
    // A repository with main and a feature bookmark, pushed by Andy's client.
137
    let repo = RepoName::parse("andy/demo").unwrap();
138
    app.repos.create(&repo, None).unwrap();
139
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
140
    let work = tempfile::tempdir().unwrap();
141
    let mut client = Repo::init(work.path()).unwrap();
142
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
143
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
144
    client.config.set("remote.origin.token", "site").unwrap();
145
    client.config.set("core.sign", "change").unwrap();
146
    client.save_config().unwrap();
147
    write(work.path(), "a.txt", b"one\ntwo\n");
148
    write(work.path(), ".oxyowners", b"* @andy\n");
149
    client.snapshot("t").unwrap();
150
    let base = client.commit("base", &[]).unwrap();
151
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
152
    client.bookmark_create("feature", Some(&[base])).unwrap();
153
    client.bookmark_switch("feature", false).unwrap();
154
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
155
    client.snapshot("t").unwrap();
156
    let feat = client.commit("add three", &[]).unwrap();
157
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
158
3d1e74cd Andy McCall ↶ 159
    // The compare page offers the proposal to writers only (hub spec §6.3).
160
    let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await;
161
    assert!(cmp.body.contains("href=\"/andy/demo/proposals/new?source=feature&#38;target=main\">Propose this"), "{}", cmp.body);
162
    assert!(!get(app, "/andy/demo/compare/main...feature", Some(&bob)).await.body.contains("Propose this"));
163
    assert!(
164
        !get(app, "/andy/demo/compare/feature...main", Some(&andy)).await.body.contains("Propose this"),
165
        "nothing to propose the other way"
166
    );
167
de505df5 Andy McCall ↶ 168
    // Bob previews and opens a proposal feature → main.
169
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
170
    assert_eq!(preview.status, StatusCode::OK);
171
    assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body);
172
    let csrf = csrf_of(&preview.body);
173
    let r =
174
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature"))
175
            .await;
176
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
177
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await;
178
    assert_eq!(page.status, StatusCode::OK);
179
    assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body);
180
    assert!(page.body.contains("Please"));
181
    assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1"));
3d1e74cd Andy McCall ↶ 182
    let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await;
183
    assert!(cmp.body.contains("proposal #1 is open") && !cmp.body.contains("Propose this"), "the open proposal instead");
de505df5 Andy McCall ↶ 184
185
    // A comment anchored to line 3 of a.txt lands in the thread on the files tab.
186
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await;
187
    assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body);
188
    let csrf_b = csrf_of(&files.body);
a1b98695 Andy McCall ↶ 189
    let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await;
de505df5 Andy McCall ↶ 190
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
191
    assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body);
192
    let c = db::proposals::comments(&app.db, 1).await.unwrap();
193
    assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced");
194
b5a0a69b Andy McCall ↶ 195
    // Live (oxygenhub#205): the page marks what updates itself; a comment
196
    // is heard on the proposal's topic; each part can be fetched alone.
197
    let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
198
    let topic = oxygenhub::events::topic::proposal(demo.id, 1);
199
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
200
    assert!(page.contains(&format!("data-live=\"{topic}\" data-fragment=\"timeline\"")), "the timeline is live");
201
    assert!(page.contains(&format!("data-live=\"{topic} repo:{}\" data-fragment=\"landing\"", demo.id)), "landing hears checks too");
202
    let csrf_c = csrf_of(&page);
203
    act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_c}&body=heard+live")).await;
33d98147 Andy McCall 204
    let heard = match app.bus.live.since(0, std::slice::from_ref(&topic)) {
b5a0a69b Andy McCall ↶ 205
        oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::<Vec<_>>(),
206
        oxygenhub::events::Replay::Reload => vec![],
207
    };
208
    assert!(heard.contains(&"comment".to_string()), "{heard:?}");
209
    let part = get(app, "/andy/demo/proposal/1?fragment=timeline", Some(&bob)).await;
210
    assert_eq!(part.status, StatusCode::OK);
211
    assert!(
212
        part.body.contains("heard live") && !part.body.contains("<html") && !part.body.contains("Review this proposal"),
213
        "{}",
214
        part.body
215
    );
216
    let landing = get(app, "/andy/demo/proposal/1?fragment=landing", Some(&bob)).await;
217
    assert!(landing.body.contains("<h3>Landing</h3>") && !landing.body.contains("heard live"), "{}", landing.body);
218
    assert_eq!(get(app, "/andy/demo/proposal/1?fragment=nope", Some(&bob)).await.status, StatusCode::NOT_FOUND);
219
de505df5 Andy McCall ↶ 220
    // Requirements: one sealed approval and code owners.
221
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
222
    let csrf_a = csrf_of(&settings.body);
223
    assert_eq!(
224
        post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1"))
225
            .await
226
            .status,
c7067c7b Andy McCall 227
        StatusCode::SEE_OTHER,
228
        "back to Settings › Landing rules"
de505df5 Andy McCall ↶ 229
    );
230
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
231
    assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body);
232
    assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body);
233
234
    // Bob cannot approve his own; Andy approves, which is pending until sealed.
235
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body);
a1b98695 Andy McCall ↶ 236
    let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await;
de505df5 Andy McCall ↶ 237
    assert!(r.body.contains("cannot approve your own"), "{}", r.body);
a1b98695 Andy McCall ↶ 238
    let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await;
de505df5 Andy McCall ↶ 239
    assert!(r.body.contains("seal pending"), "{}", r.body);
240
    assert!(r.body.contains("oxy seal --proposal"));
241
242
    // Andy links his key, seals the proposal's change and pushes the seal: the approval counts.
243
    let key = client.config.author_key.unwrap();
244
    let identity = client.identity(&key).unwrap();
245
    db::accounts::link_identity(
246
        &app.db,
247
        db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id,
248
        &key.to_hex(),
249
        &identity.name,
250
        &identity.email,
251
        &identity.encode(),
252
    )
253
    .await
254
    .unwrap();
255
    let seal = client.seal(&[feat]).unwrap();
256
    client.push_seals("origin", &[seal]).unwrap();
257
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
258
    assert!(page.body.contains("approved and sealed"), "{}", page.body);
259
    assert!(page.body.contains("ready to land"), "{}", page.body);
260
    // The API agrees, and names the projected heads for `oxy proposal land`.
261
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
262
    assert_eq!(api.status, StatusCode::OK);
263
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
264
    assert_eq!(v["landable"], true, "{}", api.body);
265
    assert_eq!(v["approvals"][0]["sealed"], true);
266
    assert_eq!(v["projected_heads"][0], feat.to_hex());
267
    assert_eq!(v["changes"][0]["hash"], feat.to_hex());
268
269
    // Land from the hub (no policy): main moves to the projected heads.
270
    let csrf_a = csrf_of(&page.body);
a1b98695 Andy McCall ↶ 271
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await;
de505df5 Andy McCall ↶ 272
    assert!(r.body.contains("Landed."), "{}", r.body);
273
    let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap();
274
    assert_eq!(heads, vec![feat]);
275
    assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1"));
0b20f1ec Andy McCall ↶ 276
    assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)"));
277
    // Landed, it still shows what it landed, by whom and how, and nothing holds it back.
278
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
279
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
280
    assert_eq!(v["state"], "landed", "{}", api.body);
281
    assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body);
282
    assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body);
283
    assert_eq!(v["landed"]["by"], "andy");
284
    assert_eq!(v["landed"]["via"], "web");
d7e2c275 Andy McCall ↶ 285
    assert_eq!(v["approvals"][0]["sealed"], true, "{}", api.body);
286
    // The list says the same from what was recorded, without projecting
287
    // it again (#331), newest first, and takes a limit.
288
    let list = send(app, "GET", "/api/v1/repos/andy/demo/proposals?state=landed", None, None, Some("site")).await;
289
    let l: serde_json::Value = serde_json::from_str(&list.body).unwrap();
290
    assert_eq!(l[0]["number"], 1, "{}", list.body);
291
    assert_eq!(l[0]["changes"][0]["hash"], feat.to_hex(), "{}", list.body);
292
    assert_eq!(l[0]["projected_heads"][0], feat.to_hex(), "{}", list.body);
293
    assert_eq!(l[0]["approvals"][0]["sealed"], true, "{}", list.body);
294
    let list = send(app, "GET", "/api/v1/repos/andy/demo/proposals?state=all&limit=0", None, None, Some("site")).await;
295
    assert_eq!(list.body.trim(), "[]");
0b20f1ec Andy McCall ↶ 296
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
297
    assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body);
4f37afcc Andy McCall ↶ 298
    // oxygenhub#86: a landed card offering to delete the source bookmark,
299
    // the description edited in place from its ··· menu or the pencil.
300
    assert!(page.body.contains("Proposal landed") && page.body.contains("value=\"feature\""), "{}", page.body);
301
    assert!(page.body.contains("can be safely deleted"), "{}", page.body);
302
    assert!(page.body.contains("class=\"head-pencil\"") && page.body.contains("id=\"edit\"") && page.body.contains("Copy link"));
303
    assert!(!page.body.contains("Edit title, target and description</summary>"), "no disclosure at the bottom");
304
    let csrf = csrf_of(&page.body);
305
    let r = post(app, "/andy/demo/bookmarks/delete", Some(&andy), &format!("_csrf={csrf}&bookmark=feature")).await;
306
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
307
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
308
    assert!(page.body.contains("Proposal landed") && !page.body.contains("can be safely deleted"), "gone, so not offered again");
0b20f1ec Andy McCall ↶ 309
    // Lists by state through the API.
310
    let list = |q: &'static str| async move {
311
        let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await;
312
        serde_json::from_str::<serde_json::Value>(&r.body).unwrap().as_array().unwrap().len()
313
    };
314
    assert_eq!(list("").await, 0, "open ones only by default");
315
    assert_eq!(list("?state=landed").await, 1);
316
    assert_eq!(list("?state=all").await, 1);
de505df5 Andy McCall ↶ 317
318
    // A proposal from a fork: a copy of the repository with the same id.
319
    let fork = RepoName::parse("bob/demo").unwrap();
320
    app.repos.create(&fork, Some(client.id().unwrap())).unwrap();
321
    db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap();
322
    db::accounts::set_collaborator(
323
        &app.db,
324
        db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id,
325
        Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id),
326
        None,
327
        "admin",
328
    )
329
    .await
330
    .unwrap();
331
    client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap();
332
    client.config.set("remote.fork.token", "site").unwrap();
333
    client.bookmark_switch("main", false).unwrap();
334
    client.fetch("origin").unwrap();
335
    client.bookmark_set("main", &[feat]).unwrap();
336
    client.bookmark_create("fix", Some(&[feat])).unwrap();
337
    client.bookmark_switch("fix", false).unwrap();
338
    write(work.path(), "b.txt", b"b\n");
339
    client.snapshot("t").unwrap();
340
    let fix = client.commit("add b", &[]).unwrap();
341
    assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok));
342
    let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await;
343
    assert!(preview.body.contains("add b"), "fork preview: {}", preview.body);
344
    let csrf = csrf_of(&preview.body);
345
    let r =
346
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await;
347
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
348
    let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await;
349
    assert!(page.body.contains("add b"), "{}", page.body);
350
    assert!(
351
        app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(),
352
        "the fork's change was copied into the target's store"
353
    );
354
    // Closing and reopening.
355
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body);
a1b98695 Andy McCall ↶ 356
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await;
de505df5 Andy McCall ↶ 357
    assert!(r.body.contains(">closed<"), "{}", r.body);
a1b98695 Andy McCall ↶ 358
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
de505df5 Andy McCall ↶ 359
    assert!(r.body.contains(">open<"), "{}", r.body);
360
}
7d2eb590 Andy McCall ↶ 361
362
/// A stand-in chat-completions endpoint that answers with a fixed review
363
/// and keeps the body of each call.
364
async fn mock_assistant(answer: &'static str) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
365
    use std::sync::{Arc, Mutex};
366
    let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
367
    let s2 = seen.clone();
368
    let router = axum::Router::new().route(
369
        "/v1/chat/completions",
370
        axum::routing::post(move |headers: axum::http::HeaderMap, body: String| {
371
            let s2 = s2.clone();
372
            async move {
373
                let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
374
                s2.lock().unwrap().push(format!("{auth} {body}"));
375
                axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": answer } }] }))
376
            }
377
        }),
378
    );
379
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
380
    let port = listener.local_addr().unwrap().port();
381
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
382
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
383
}
384
385
#[tokio::test(flavor = "multi_thread")]
386
async fn an_assistant_reviews_a_proposal_via_the_person_who_asked() {
387
    let h = hub().await;
388
    let app = &h.app;
389
    let andy = register(app, "andy").await;
390
    let bob = register(app, "bob").await;
391
    let repo = RepoName::parse("andy/demo").unwrap();
392
    app.repos.create(&repo, None).unwrap();
393
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
394
    let work = tempfile::tempdir().unwrap();
395
    let mut client = Repo::init(work.path()).unwrap();
396
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
397
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
398
    client.config.set("remote.origin.token", "site").unwrap();
399
    client.config.set("core.sign", "change").unwrap();
400
    client.save_config().unwrap();
401
    write(work.path(), "a.txt", b"one\ntwo\n");
402
    client.snapshot("t").unwrap();
403
    let base = client.commit("base", &[]).unwrap();
404
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
405
    client.bookmark_create("feature", Some(&[base])).unwrap();
406
    client.bookmark_switch("feature", false).unwrap();
407
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
408
    client.snapshot("t").unwrap();
409
    client.commit("add three", &[]).unwrap();
410
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
411
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
412
    let csrf_b = csrf_of(&preview.body);
413
    let r = post(
414
        app,
415
        "/andy/demo/proposals/new",
416
        Some(&bob),
417
        &format!("_csrf={csrf_b}&title=Add+three&body=Please&target=main&source=feature"),
418
    )
419
    .await;
420
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
421
422
    // Nothing to ask until the site has an assistant.
423
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
ed89fae5 Andy McCall ↶ 424
    assert!(!page.contains("Ask Claude"), "{page}");
7d2eb590 Andy McCall ↶ 425
    let (endpoint, seen) = mock_assistant("**Summary.** Adds a third line.\n\n- a.txt line 3: fine.").await;
426
    let admin = get(app, "/admin", Some(&andy)).await.body;
427
    let csrf_a = csrf_of(&admin);
428
    let ep = endpoint.replace(':', "%3A").replace('/', "%2F");
429
    let r = post(
430
        app,
431
        "/admin/assistants",
432
        Some(&andy),
433
        &format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"),
434
    )
435
    .await;
206c1fc1 Andy McCall ↶ 436
    assert!(r.body.contains("Claude added and answered"), "{}", r.body);
437
    seen.lock().unwrap().clear();
7d2eb590 Andy McCall ↶ 438
    let r =
439
        post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await;
440
    assert!(r.body.contains("Prompt saved."), "{}", r.body);
441
442
    // Claude drafts a description for a proposal not yet opened, and for
443
    // the open one; neither is saved until the person says so.
444
    let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await.body;
ed89fae5 Andy McCall ↶ 445
    assert!(page.contains("Draft with Claude"), "{page}");
7d2eb590 Andy McCall ↶ 446
    let r = post(
447
        app,
448
        "/andy/demo/proposals/new/draft",
449
        Some(&bob),
450
        &format!("_csrf={}&title=Add+three&body=&target=main&source=feature&assistant=Claude", csrf_of(&page)),
451
    )
452
    .await;
453
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
454
    assert!(r.body.contains("rows=\"8\">**Summary.** Adds a third line.") && r.body.contains("value=\"Add three\""), "{}", r.body);
455
    {
456
        let calls = seen.lock().unwrap();
457
        assert_eq!(calls.len(), 1);
458
        assert!(
459
            calls[0].contains("drafting the description") && calls[0].contains("+three") && calls[0].contains("Proposal: Add three"),
460
            "{}",
461
            calls[0]
462
        );
c43e4f63 Andy McCall ↶ 463
        // A proposal not yet opened gets a title too (#316); the person's own is kept.
464
        assert!(calls[0].contains("Begin with one line `Title: `"), "{}", calls[0]);
7d2eb590 Andy McCall ↶ 465
    }
466
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
ed89fae5 Andy McCall ↶ 467
    assert!(page.contains("Draft with Claude"), "{page}");
7d2eb590 Andy McCall ↶ 468
    let r =
469
        post(app, "/andy/demo/proposal/1/draft", Some(&bob), &format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page))).await;
470
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
471
    assert!(
472
        r.body.contains("tl-editform open") && r.body.contains("aria-label=\"Description (Markdown)\">**Summary.** Adds a third line."),
473
        "{}",
474
        r.body
475
    );
476
    assert!(r.body.contains("nothing is saved until you update"), "{}", r.body);
477
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "the description is untouched");
478
    assert_eq!(seen.lock().unwrap().len(), 2);
479
    seen.lock().unwrap().clear();
480
119fd17d Andy McCall ↶ 481
    // The same as a stream for the page to show as it comes (#248): the
482
    // button says where, and the draft arrives in parts, then whole.
483
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
484
    assert!(page.contains("data-stream=\"/andy/demo/proposal/1/draft/stream\""), "{page}");
485
    let form = format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page));
486
    let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form).await;
487
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
488
    assert!(r.body.contains("event: part") && r.body.contains("event: done"), "{}", r.body);
489
    assert!(r.body.contains("Adds a third line."), "{}", r.body);
490
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "still nothing saved");
491
    let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form.replace("Claude", "Nobody")).await;
492
    assert!(r.body.contains("event: error") && r.body.contains("No assistant called Nobody"), "{}", r.body);
493
    seen.lock().unwrap().clear();
494
7d2eb590 Andy McCall ↶ 495
    // Andy approves; then Bob, the author, asks Claude.
496
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
497
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await;
498
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
499
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
ed89fae5 Andy McCall ↶ 500
    assert!(page.contains("Ask Claude"), "{page}");
7d2eb590 Andy McCall ↶ 501
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=claude", csrf_of(&page))).await;
502
    assert!(r.body.contains("Asked Claude to review"), "{}", r.body);
503
    let mut reviews = vec![];
504
    for _ in 0..100 {
505
        reviews = db::proposals::reviews(&app.db, 1).await.unwrap();
506
        if reviews.iter().any(|r| r.via == "Claude") {
507
            break;
508
        }
509
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
510
    }
511
    let claude = reviews.iter().find(|r| r.via == "Claude").expect("the assistant's review arrived");
512
    assert_eq!((claude.author.as_str(), claude.verdict.as_str()), ("bob", "comment"));
513
    assert!(claude.body.contains("Adds a third line"));
514
    {
515
        let calls = seen.lock().unwrap();
516
        assert_eq!(calls.len(), 1);
517
        let c = &calls[0];
518
        assert!(c.starts_with("Bearer sk-test "), "{c}");
519
        assert!(
520
            c.contains("You are Claude") && c.contains("Review for correctness first."),
521
            "the system prompt and the site's prompt: {c}"
522
        );
523
        assert!(c.contains("Proposal #1: Add three") && c.contains("a.txt (modified)") && c.contains("+three"), "the context: {c}");
524
    }
525
    // Shown as Bob via Claude, with the event; Andy's approval still counts.
526
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
527
    assert!(
528
        page.contains("<strong>bob</strong></a> <span class=\"mute\" aria-hidden=\"true\">↳</span> <span class=\"tag\"")
529
            && page.contains("<strong>Claude</strong>"),
530
        "{page}"
531
    );
532
    assert!(page.contains("asked Claude to review") && page.contains("Adds a third line"), "{page}");
533
    let p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
534
    let loaded = oxygenhub::web::proposals::load(app, &repo, p).await.unwrap();
535
    let v = oxygenhub::web::proposals::verdict(app, &loaded, 1).await.unwrap();
536
    assert!(v.approvals.iter().any(|a| a.account == "andy"), "{:?}", v.approvals.iter().map(|a| &a.account).collect::<Vec<_>>());
ffb67a81 Andy McCall 537
    // Bob asks Claude for a summary (#190): it comes as a comment via Claude.
538
    assert!(page.contains("Summarise with Claude"), "{page}");
539
    seen.lock().unwrap().clear();
540
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
541
    let r =
542
        act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=Claude&task=summary", csrf_of(&page))).await;
543
    assert!(r.body.contains("Asked Claude for a summary"), "{}", r.body);
544
    let mut comments = vec![];
545
    for _ in 0..100 {
546
        comments = db::proposals::comments(&app.db, 1).await.unwrap();
547
        if comments.iter().any(|c| c.via == "Claude") {
548
            break;
549
        }
550
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
551
    }
552
    let summary = comments.iter().find(|c| c.via == "Claude").expect("the assistant's summary arrived");
553
    assert_eq!(summary.author, "bob");
554
    assert!(summary.body.contains("Adds a third line"));
555
    {
556
        let calls = seen.lock().unwrap();
557
        assert_eq!(calls.len(), 1);
558
        let c = &calls[0];
559
        assert!(
560
            c.contains("called Oxygen")
561
                && c.contains("equivalent of a merge request or pull request")
562
                && c.contains("Please summarise the proposal"),
563
            "{c}"
564
        );
565
        assert!(c.contains("Review for correctness first.") && c.contains("+three"), "the site's prompt and the context: {c}");
566
    }
567
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
568
    assert!(page.contains("asked Claude for a summary") && page.contains("wrote this for bob"), "{page}");
7d2eb590 Andy McCall ↶ 569
    // The API lists the review via Claude and takes requests too.
570
    let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await;
571
    assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body);
ed89fae5 Andy McCall ↶ 572
    // Renaming the assistant renames it on the review and in the events.
573
    let admin = get(app, "/admin", Some(&andy)).await.body;
574
    let r = post(
575
        app,
576
        "/admin/assistants",
577
        Some(&andy),
578
        &format!(
579
            "_csrf={}&action=update&id={}&name=Claude+Code&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1",
580
            csrf_of(&admin),
581
            oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0].id
582
        ),
583
    )
584
    .await;
585
    assert!(r.body.contains("Claude Code updated."), "{}", r.body);
586
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
587
    assert!(page.contains("<strong>Claude Code</strong>") && page.contains("asked Claude Code to review"), "{page}");
588
    assert!(!page.contains("<strong>Claude</strong>"), "{page}");
7d2eb590 Andy McCall ↶ 589
    // A stranger cannot ask; an unknown assistant is refused.
590
    post(app, "/andy/demo/proposal/1/assistant", None, "_csrf=x&assistant=Claude").await;
591
    assert_eq!(seen.lock().unwrap().len(), 1, "no call for a stranger");
592
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
593
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=HAL", csrf_of(&page))).await;
594
    assert!(r.body.contains("No assistant called HAL"), "{}", r.body);
595
}
ac713df7 Andy McCall ↶ 596
597
/// #141: a proposal whose work reaches its target another way (a client's
598
/// `oxy proposal land`, as a trust policy needs, or a merge pushed by hand)
599
/// is marked landed by that push, not left open with nothing to land.
600
#[tokio::test(flavor = "multi_thread")]
601
async fn a_proposal_landed_by_a_push_is_marked_landed() {
602
    let h = hub().await;
603
    let app = &h.app;
604
    let andy = register(app, "andy").await;
605
    let repo = RepoName::parse("andy/demo").unwrap();
606
    app.repos.create(&repo, None).unwrap();
607
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
608
    let work = tempfile::tempdir().unwrap();
609
    let mut client = Repo::init(work.path()).unwrap();
610
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
611
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
612
    client.config.set("remote.origin.token", "site").unwrap();
613
    client.save_config().unwrap();
614
    write(work.path(), "a.txt", b"one\n");
615
    client.snapshot("t").unwrap();
616
    let base = client.commit("base", &[]).unwrap();
617
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
618
    client.bookmark_create("feature", Some(&[base])).unwrap();
619
    client.bookmark_switch("feature", false).unwrap();
620
    write(work.path(), "a.txt", b"one\ntwo\n");
621
    client.snapshot("t").unwrap();
622
    let feat = client.commit("add two", &[]).unwrap();
623
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
624
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
625
    let csrf = csrf_of(&preview.body);
626
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await;
627
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
628
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state, "open");
629
630
    // The work goes onto main by a push, not by the Land button.
631
    client.bookmark_set("main", &[feat]).unwrap();
632
    assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok));
633
    let mut p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
634
    for _ in 0..100 {
635
        if p.state == "landed" {
636
            break;
637
        }
638
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
639
        p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
640
    }
641
    assert_eq!((p.state.as_str(), p.landed_via.as_str()), ("landed", "push"), "{p:?}");
668b2ad7 Andy McCall ↶ 642
643
    // #329: more work pushed to the landed proposal's bookmark is told
644
    // that it reaches main only through a new proposal.
645
    client.bookmark_switch("feature", false).unwrap();
646
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
647
    client.snapshot("t").unwrap();
648
    client.commit("add three", &[]).unwrap();
649
    let results = client.push_bookmark("origin", "feature", false).unwrap();
650
    let feature = results.iter().find(|r| r.name == "feature").expect("the feature bookmark's result");
651
    assert!(feature.ok, "{results:?}");
652
    assert!(
653
        feature.reason.contains("proposal #1 has landed in main") && feature.reason.contains("oh proposal create --from feature"),
654
        "{results:?}"
655
    );
656
    // The landed proposal's page still lists what it landed, and points at
657
    // the change that came after.
658
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
659
    assert!(page.body.contains("has 1 change since this landed"), "{}", page.body);
660
    assert!(page.body.contains("add three") && page.body.contains("proposals/new?source=feature&amp;target=main"), "{}", page.body);
661
    let at = page.body.find("Proposal landed").expect("the landed outcome");
662
    let outcome = &page.body[at..at + 600];
663
    // It landed by a push before any projection recorded its changes, so it
664
    // claims none of the new one as its own, and its bookmark is not
665
    // offered for deletion while it holds work.
666
    assert!(!outcome.contains("1 change into") && !outcome.contains("can be safely deleted"), "{outcome}");
ac713df7 Andy McCall ↶ 667
}
71e65fcf Andy McCall ↶ 668
669
/// #140: a proposal is closed and reopened through the API, as the page's
670
/// buttons do, by its author or someone with triage access.
671
#[tokio::test(flavor = "multi_thread")]
672
async fn a_proposal_is_closed_and_reopened_through_the_api() {
673
    let h = hub().await;
674
    let app = &h.app;
675
    let andy = register(app, "andy").await;
676
    let repo = RepoName::parse("andy/demo").unwrap();
677
    app.repos.create(&repo, None).unwrap();
678
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
679
    let work = tempfile::tempdir().unwrap();
680
    let mut client = Repo::init(work.path()).unwrap();
681
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
682
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
683
    client.config.set("remote.origin.token", "site").unwrap();
684
    client.save_config().unwrap();
685
    write(work.path(), "a.txt", b"one\n");
686
    client.snapshot("t").unwrap();
687
    let base = client.commit("base", &[]).unwrap();
688
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
689
    client.bookmark_create("feature", Some(&[base])).unwrap();
690
    client.bookmark_switch("feature", false).unwrap();
691
    write(work.path(), "a.txt", b"one\ntwo\n");
692
    client.snapshot("t").unwrap();
693
    client.commit("add two", &[]).unwrap();
694
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
695
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
696
    let csrf = csrf_of(&preview.body);
697
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await;
698
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
699
700
    let patch = |json: &'static str, bearer: Option<&'static str>| async move {
701
        let mut req =
702
            Request::builder().method("PATCH").uri("/api/v1/repos/andy/demo/proposals/1").header(header::CONTENT_TYPE, "application/json");
703
        if let Some(b) = bearer {
704
            req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
705
        }
706
        let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(json)).unwrap()).await.unwrap();
707
        let st = res.status();
708
        (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
709
    };
710
    let state = || async { db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state };
711
    // The site token acts for the first administrator, andy.
712
    let (st, body) = patch(r#"{"state":"closed"}"#, Some("site")).await;
713
    assert_eq!(st, StatusCode::OK, "{body}");
714
    assert_eq!(state().await, "closed");
715
    assert_eq!(patch(r#"{"state":"closed"}"#, Some("site")).await.0, StatusCode::OK, "closing a closed proposal changes nothing");
716
    let (st, _) = patch(r#"{"state":"open"}"#, Some("site")).await;
717
    assert_eq!(st, StatusCode::OK);
718
    assert_eq!(state().await, "open");
719
    assert_eq!(patch(r#"{"state":"landed"}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "landing has its own endpoint");
720
    assert_ne!(patch(r#"{"state":"closed"}"#, None).await.0, StatusCode::OK, "not without a token");
721
    assert_eq!(state().await, "open");
09ffa7b6 Andy McCall ↶ 722
    // In and out of draft (#313), as the page's Convert to draft and Ready
723
    // for review do, by `state` or by `draft` as at creation.
724
    assert_eq!(patch(r#"{"draft":true}"#, Some("site")).await.0, StatusCode::OK);
725
    assert_eq!(state().await, "draft");
726
    let (st, body) = patch(r#"{"draft":false}"#, Some("site")).await;
727
    assert_eq!(st, StatusCode::OK, "{body}");
728
    assert_eq!(state().await, "open", "a draft made ready");
729
    assert_eq!(patch(r#"{"state":"draft"}"#, Some("site")).await.0, StatusCode::OK);
730
    assert_eq!(state().await, "draft");
731
    assert_eq!(patch(r#"{"state":"open"}"#, Some("site")).await.0, StatusCode::OK);
732
    assert_eq!(state().await, "open");
733
    assert_eq!(patch(r#"{"state":"open","draft":true}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "they disagree");
5a9a6e9a Andy McCall ↶ 734
    // A field PATCH does not take is refused, naming it, rather than a 200
735
    // that changes nothing (#330: `{"ready": true}` was taken that way).
736
    let (st, body) = patch(r#"{"ready":true}"#, Some("site")).await;
737
    assert!(st.is_client_error() && body.contains("unknown field `ready`"), "{st} {body}");
738
    assert_eq!(state().await, "open");
09ffa7b6 Andy McCall ↶ 739
    patch(r#"{"state":"closed"}"#, Some("site")).await;
740
    assert_eq!(patch(r#"{"draft":true}"#, Some("site")).await.0, StatusCode::CONFLICT, "a closed proposal is reopened, not drafted");
71e65fcf Andy McCall ↶ 741
}
acc85ac0 Andy McCall ↶ 742
2a8b6216 Andy McCall ↶ 743
/// Landing over checks (#261): with required checks not passed, a
744
/// maintainer may land now with a reason, recorded and audited; a writer
745
/// may not, nor anyone without a reason.
746
#[tokio::test(flavor = "multi_thread")]
747
async fn a_maintainer_lands_over_checks_with_a_reason() {
748
    let h = hub().await;
749
    let app = &h.app;
750
    let andy = register(app, "andy").await;
751
    let bob = register(app, "bob").await;
752
    let repo = RepoName::parse("andy/demo").unwrap();
753
    app.repos.create(&repo, None).unwrap();
754
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
755
    let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
756
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
757
    db::accounts::set_collaborator(&app.db, row.id, Some(bob_id), None, "write").await.unwrap();
758
    let work = tempfile::tempdir().unwrap();
759
    let mut client = Repo::init(work.path()).unwrap();
760
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
761
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
762
    client.config.set("remote.origin.token", "site").unwrap();
763
    client.save_config().unwrap();
764
    write(work.path(), "a.txt", b"one\n");
765
    client.snapshot("t").unwrap();
766
    let base = client.commit("base", &[]).unwrap();
767
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
768
    client.bookmark_create("feature", Some(&[base])).unwrap();
769
    client.bookmark_switch("feature", false).unwrap();
770
    write(work.path(), "b.txt", b"b\n");
771
    client.snapshot("t").unwrap();
772
    client.commit("the fix", &[]).unwrap();
773
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
774
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
775
    let r = post(
776
        app,
777
        "/andy/demo/proposals/new",
778
        Some(&andy),
779
        &format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)),
780
    )
781
    .await;
782
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
783
    db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap();
784
785
    // The checks block it; the maintainer is offered landing over them.
786
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
787
    assert!(page.body.contains("required checks not passed") && page.body.contains("Land now, over the checks"), "{}", page.body);
788
    let csrf = csrf_of(&page.body);
789
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}")).await;
790
    assert!(r.body.contains("Cannot land: required checks not passed"), "plain land is refused: {}", r.body);
791
    assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open");
792
    // A writer is not offered it and cannot do it.
793
    let bob_page = get(app, "/andy/demo/proposal/1", Some(&bob)).await;
794
    assert!(!bob_page.body.contains("Land now, over the checks"));
795
    let r = post(app, "/andy/demo/proposal/1/land", Some(&bob), &format!("_csrf={}&over_checks=please", csrf_of(&bob_page.body))).await;
796
    assert_eq!(r.status, StatusCode::NOT_FOUND, "a writer cannot land over checks");
797
    assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open");
798
    // No reason, no landing.
799
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=+")).await;
800
    assert!(r.body.contains("needs a reason"), "{}", r.body);
801
    assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open");
802
    // With one: landed, and it says so.
803
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=the+runners+are+down")).await;
804
    assert!(r.body.contains("over checks: the runners are down"), "the landing says so: {}", r.body);
805
    let p = db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap();
806
    assert_eq!(p.state, "landed");
807
    assert!(p.landed_via.contains("over checks: the runners are down"), "{}", p.landed_via);
808
    let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap();
809
    assert!(audit
810
        .iter()
811
        .any(|a| a.action == "proposal.land_over_checks" && a.detail.contains("the runners are down") && a.detail.contains("flow/test")));
812
}
813
2abea308 Andy McCall ↶ 814
/// A landed proposal's runs that no runner has started are cancelled
815
/// (#263): they hold the queue ahead of the target's own run. A running
816
/// one finishes; another bookmark's run is untouched.
817
#[tokio::test(flavor = "multi_thread")]
818
async fn landing_cancels_the_proposals_unstarted_runs() {
819
    let h = hub().await;
820
    let app = &h.app;
821
    let andy = register(app, "andy").await;
822
    let repo = RepoName::parse("andy/demo").unwrap();
823
    app.repos.create(&repo, None).unwrap();
824
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
825
    let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
826
    let work = tempfile::tempdir().unwrap();
827
    let mut client = Repo::init(work.path()).unwrap();
828
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
829
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
830
    client.config.set("remote.origin.token", "site").unwrap();
831
    client.save_config().unwrap();
832
    write(work.path(), "a.txt", b"one\n");
833
    client.snapshot("t").unwrap();
834
    let base = client.commit("base", &[]).unwrap();
835
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
836
    client.bookmark_create("feature", Some(&[base])).unwrap();
837
    client.bookmark_switch("feature", false).unwrap();
838
    write(work.path(), "b.txt", b"b\n");
839
    client.snapshot("t").unwrap();
840
    client.commit("the fix", &[]).unwrap();
841
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
842
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
843
    let r = post(
844
        app,
845
        "/andy/demo/proposals/new",
846
        Some(&andy),
847
        &format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)),
848
    )
849
    .await;
850
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
851
852
    // Runs as the queue holds them: the source's push runs (one queued,
853
    // one running), the proposal's own, and another bookmark's.
854
    let run = |bookmark: &'static str, trigger: &'static str| {
855
        let app = app.clone();
856
        let repo_id = row.id;
857
        async move {
858
            let job = db::flow::NewJob {
859
                name: "test".into(),
860
                depth: 0,
861
                depends: vec![],
862
                spec: "{}".into(),
863
                sandbox: "{}".into(),
864
                runtime: "process".into(),
865
                need_disk_mib: None,
5588bb36 Andy McCall ↶ 866
                reused: None,
2abea308 Andy McCall ↶ 867
            };
868
            let new =
869
                db::flow::NewRun { repo_id, bookmark, state: "s", heads: "h", pipeline_hash: "", actor: "andy", trigger, error: None };
870
            db::flow::create_run(&app.db, new, &[job]).await.unwrap()
871
        }
872
    };
873
    let queued = run("feature", "push").await;
874
    let running = run("feature", "push").await;
875
    let own = run("proposal/1", "proposal").await;
876
    let other = run("other", "push").await;
877
    sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(running).execute(&app.db).await.unwrap();
878
    sqlx::query("UPDATE flow_jobs SET status = 'running' WHERE run_id = $1").bind(running).execute(&app.db).await.unwrap();
879
880
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
881
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={}", csrf_of(&page.body))).await;
882
    assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "landed", "{}", r.body);
883
884
    let status = |id: i64| {
885
        let app = app.clone();
886
        async move {
887
            let run = db::flow::run_by_id(&app.db, id).await.unwrap().unwrap();
888
            let jobs = db::flow::jobs(&app.db, id).await.unwrap();
889
            (run.status, jobs[0].error.clone())
890
        }
891
    };
892
    for id in [queued, own] {
893
        let (st, why) = status(id).await;
894
        assert_eq!(st, "cancelled");
895
        assert_eq!(why, "moot: proposal #1 landed");
896
    }
897
    assert_eq!(status(running).await.0, "running", "a started run finishes");
898
    assert_eq!(status(other).await.0, "queued", "another bookmark's run is untouched");
899
}
900
86a69b53 Andy McCall ↶ 901
/// Land when checks pass (#261): set by someone who may land, it lands as
902
/// them once the required check passes on the projected state; a failing
903
/// check turns it off, with a note; it can be turned off.
904
#[tokio::test(flavor = "multi_thread")]
905
async fn a_proposal_lands_itself_when_its_checks_pass() {
906
    let h = hub().await;
907
    let app = &h.app;
908
    let andy = register(app, "andy").await;
909
    let repo = RepoName::parse("andy/demo").unwrap();
910
    app.repos.create(&repo, None).unwrap();
911
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
912
    let row = &db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
913
    let work = tempfile::tempdir().unwrap();
914
    let mut client = Repo::init(work.path()).unwrap();
915
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
916
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
917
    client.config.set("remote.origin.token", "site").unwrap();
918
    client.save_config().unwrap();
919
    write(work.path(), "a.txt", b"one\n");
920
    client.snapshot("t").unwrap();
921
    let base = client.commit("base", &[]).unwrap();
922
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
923
    for (bm, file) in [("one", "b.txt"), ("two", "c.txt")] {
924
        client.bookmark_create(bm, Some(&[base])).unwrap();
925
        client.bookmark_switch(bm, false).unwrap();
926
        write(work.path(), file, b"x\n");
927
        client.snapshot("t").unwrap();
928
        client.commit(&format!("on {bm}"), &[]).unwrap();
929
        assert!(client.push_bookmark("origin", bm, false).unwrap().iter().all(|r| r.ok));
930
        let preview = get(app, &format!("/andy/demo/proposals/new?source={bm}&target=main"), Some(&andy)).await;
931
        let r = post(
932
            app,
933
            "/andy/demo/proposals/new",
934
            Some(&andy),
935
            &format!("_csrf={}&title={bm}&body=&target=main&source={bm}", csrf_of(&preview.body)),
936
        )
937
        .await;
938
        assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
939
    }
940
    db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap();
941
    let check = |number: i64, status: &'static str| async move {
942
        let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap();
943
        let report =
944
            db::hooks::CheckReport { state: &p.projected_state, name: "flow/test", status, summary: "", url: "", reporter: None, seal: "" };
945
        db::hooks::upsert_check(&app.db, row.id, report).await.unwrap();
946
        oxygenhub::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": { "state": p.projected_state } })).await;
947
    };
948
    let state_of = |number: i64| async move {
949
        for _ in 0..100 {
950
            let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap();
951
            if p.state != "open" || p.auto_land_by.is_empty() {
952
                return p;
953
            }
954
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
955
        }
956
        db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap()
957
    };
958
959
    // #1: set while its check is pending; it waits, then lands as Andy.
960
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
961
    assert!(page.body.contains("LAND WHEN CHECKS PASS"), "{}", page.body);
962
    let r = act(app, "/andy/demo/proposal/1/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await;
963
    assert!(r.body.contains("lands when its checks pass (set by"), "{}", r.body);
964
    assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "the check has not passed");
965
    check(1, "running").await;
966
    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
967
    assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "still running");
968
    check(1, "passed").await;
969
    let p = state_of(1).await;
970
    assert_eq!((p.state.as_str(), p.landed_by.as_str()), ("landed", "andy"));
971
    assert!(p.landed_via.contains("when its checks passed (set by andy)"), "{}", p.landed_via);
972
973
    // #2: a failing check turns it off, with a note; it stays open.
974
    let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await;
975
    act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await;
976
    check(2, "failed").await;
977
    let p = state_of(2).await;
978
    assert_eq!((p.state.as_str(), p.auto_land_by.as_str()), ("open", ""));
979
    let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await;
980
    assert!(page.body.contains("turned off landing when its checks pass") && page.body.contains("flow/test did not pass"), "{}", page.body);
981
    // Set again and turned off by hand.
982
    act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await;
983
    let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await;
984
    let r = act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=0", csrf_of(&page.body))).await;
985
    assert!(r.body.contains("will not land by itself"), "{}", r.body);
986
    assert!(db::proposals::get(&app.db, row.id, 2).await.unwrap().unwrap().auto_land_by.is_empty());
987
}
988
acc85ac0 Andy McCall ↶ 989
/// Metal Gear 2's finding: a landed proposal's "Closes #N" closed nothing,
990
/// because only changes a push imports were read. Changes that arrive on
991
/// the default bookmark close issues however they get there.
992
#[tokio::test(flavor = "multi_thread")]
993
async fn closes_in_a_landed_change_closes_the_issue() {
994
    let h = hub().await;
995
    let app = &h.app;
996
    let andy = register(app, "andy").await;
997
    let repo = RepoName::parse("andy/demo").unwrap();
998
    app.repos.create(&repo, None).unwrap();
999
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1000
    let json = |method: &'static str, path: String, body: &'static str| async move {
1001
        let res = oxygenhub::router(app.clone())
1002
            .oneshot(
1003
                Request::builder()
1004
                    .method(method)
1005
                    .uri(path)
1006
                    .header(header::AUTHORIZATION, "Bearer site")
1007
                    .header(header::CONTENT_TYPE, "application/json")
1008
                    .body(Body::from(body))
1009
                    .unwrap(),
1010
            )
1011
            .await
1012
            .unwrap();
1013
        let st = res.status();
1014
        (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
1015
    };
1016
    for t in ["one", "two"] {
1017
        let body: &'static str = if t == "one" { r#"{"title":"one"}"# } else { r#"{"title":"two"}"# };
1018
        let (st, b) = json("POST", "/api/v1/repos/andy/demo/issues".into(), body).await;
1019
        assert_eq!(st, StatusCode::CREATED, "{b}");
1020
    }
1021
    let work = tempfile::tempdir().unwrap();
1022
    let mut client = Repo::init(work.path()).unwrap();
1023
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
1024
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
1025
    client.config.set("remote.origin.token", "site").unwrap();
1026
    client.save_config().unwrap();
1027
    write(work.path(), "a.txt", b"one\n");
1028
    client.snapshot("t").unwrap();
1029
    let base = client.commit("base", &[]).unwrap();
1030
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
1031
    client.bookmark_create("feature", Some(&[base])).unwrap();
1032
    client.bookmark_switch("feature", false).unwrap();
1033
    write(work.path(), "b.txt", b"b\n");
1034
    client.snapshot("t").unwrap();
1035
    client.commit("add b\n\nCloses #1", &[]).unwrap();
1036
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
1037
    let state = |n: i64| async move { db::issues::get(&app.db, 1, n).await.unwrap().unwrap().state };
1038
    assert_eq!(state(1).await, "open", "a push to another bookmark closes nothing");
1039
1040
    // Landed on the hub: #1 closes.
1041
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
1042
    let csrf = csrf_of(&preview.body);
1043
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=B&body=&target=main&source=feature")).await;
1044
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
1045
    let number = db::proposals::list(&app.db, 1, &["open"]).await.unwrap()[0].number;
1046
    let (st, b) = json("POST", format!("/api/v1/repos/andy/demo/proposals/{number}/land"), "{}").await;
1047
    assert_eq!(st, StatusCode::OK, "{b}");
1048
    assert_eq!(state(1).await, "closed", "landing closes what the change says");
1049
1050
    // A client moving main to a change the hub already holds: #2 closes.
1051
    client.bookmark_create("other", Some(&[base])).unwrap();
1052
    client.bookmark_switch("other", false).unwrap();
1053
    write(work.path(), "c.txt", b"c\n");
1054
    client.snapshot("t").unwrap();
1055
    let c = client.commit("add c, closes #2", &[]).unwrap();
1056
    assert!(client.push_bookmark("origin", "other", false).unwrap().iter().all(|r| r.ok));
1057
    assert_eq!(state(2).await, "open");
1058
    let (_, list) = json("GET", "/api/v1/repos/andy/demo/bookmarks".into(), "").await;
1059
    let list: Vec<serde_json::Value> = serde_json::from_str(&list).unwrap();
1060
    let main = list.iter().find(|b| b["name"] == "main").unwrap();
1061
    let mut heads: Vec<oxy_core::hash::ChangeHash> =
1062
        main["heads"].as_array().unwrap().iter().map(|h| oxy_core::hash::ChangeHash::from_hex(h.as_str().unwrap()).unwrap()).collect();
1063
    client.fetch("origin").unwrap();
1064
    heads.push(c);
1065
    client.bookmark_set("main", &heads).unwrap();
1066
    assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok));
1067
    let mut s2 = state(2).await;
1068
    for _ in 0..100 {
1069
        if s2 == "closed" {
1070
            break;
1071
        }
1072
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1073
        s2 = state(2).await;
1074
    }
1075
    assert_eq!(s2, "closed", "a change arriving by push closes it, though the hub held it already");
1076
}
c08cec37 Andy McCall ↶ 1077
1078
/// A stand-in that refuses a prompt longer than `limit` bytes as a provider
1079
/// does when it overflows the model, and keeps each call's length.
1080
async fn mock_limited(limit: usize) -> (String, std::sync::Arc<std::sync::Mutex<Vec<usize>>>) {
1081
    use std::sync::{Arc, Mutex};
1082
    let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(vec![]));
1083
    let s2 = seen.clone();
1084
    let router = axum::Router::new().route(
1085
        "/v1/chat/completions",
1086
        axum::routing::post(move |body: String| {
1087
            let s2 = s2.clone();
1088
            async move {
1089
                s2.lock().unwrap().push(body.len());
1090
                if body.len() > limit {
1091
                    let msg = format!("This model's maximum context length is {limit} tokens. Please reduce the length of the messages.");
1092
                    return (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": { "message": msg } })));
1093
                }
1094
                (
1095
                    StatusCode::OK,
1096
                    axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "Small draft." } }] })),
1097
                )
1098
            }
1099
        }),
1100
    );
1101
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1102
    let port = listener.local_addr().unwrap().port();
1103
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1104
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
1105
}
1106
1107
/// A proposal too large for the model (oxygenhub#248): the provider refuses
1108
/// it, the hub asks again with a third of the room, and the answer comes.
1109
#[tokio::test(flavor = "multi_thread")]
1110
async fn a_proposal_too_large_for_the_model_is_asked_again_smaller() {
1111
    let h = hub().await;
1112
    let app = &h.app;
1113
    let andy = register(app, "andy").await;
1114
    let repo = RepoName::parse("andy/demo").unwrap();
1115
    app.repos.create(&repo, None).unwrap();
1116
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1117
    let work = tempfile::tempdir().unwrap();
1118
    let mut client = Repo::init(work.path()).unwrap();
1119
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
1120
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
1121
    client.config.set("remote.origin.token", "site").unwrap();
1122
    client.save_config().unwrap();
1123
    write(work.path(), "a.txt", b"one\n");
1124
    client.snapshot("t").unwrap();
1125
    let base = client.commit("base", &[]).unwrap();
1126
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
1127
    client.bookmark_create("feature", Some(&[base])).unwrap();
1128
    client.bookmark_switch("feature", false).unwrap();
1129
    // A big change: 200 lines of 60 characters, about 12 KB of diff.
1130
    let big: String = (0..200).map(|i| format!("{i:04} {}\n", "x".repeat(55))).collect();
1131
    write(work.path(), "big.txt", big.as_bytes());
1132
    client.snapshot("t").unwrap();
1133
    client.commit("a big file", &[]).unwrap();
1134
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
1135
1136
    let (endpoint, seen) = mock_limited(6_000).await;
1137
    let admin = get(app, "/admin", Some(&andy)).await.body;
1138
    let ep = endpoint.replace(':', "%3A").replace('/', "%2F");
1139
    let r = post(
1140
        app,
1141
        "/admin/assistants",
1142
        Some(&andy),
1143
        &format!(
1144
            "_csrf={}&action=add&name=Small&endpoint={ep}&api=chat-completions&model=small-1&token=sk-test&enabled=1&budget=12000",
1145
            csrf_of(&admin)
1146
        ),
1147
    )
1148
    .await;
1149
    assert!(r.body.contains("Small added and answered"), "{}", r.body);
1150
    seen.lock().unwrap().clear();
1151
    let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await.body;
1152
    let r = post(
1153
        app,
1154
        "/andy/demo/proposals/new/draft",
1155
        Some(&andy),
1156
        &format!("_csrf={}&title=Big&body=&target=main&source=feature&assistant=Small", csrf_of(&page)),
1157
    )
1158
    .await;
1159
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
1160
    assert!(r.body.contains("Small draft."), "{}", r.body);
1161
    let calls = seen.lock().unwrap().clone();
1162
    assert_eq!(calls.len(), 2, "refused once, then asked smaller: {calls:?}");
1163
    assert!(calls[0] > 6_000 && calls[1] <= 6_000, "{calls:?}");
1164
}