OXYGENHUB
# 8f0efa0729b0 oxygenhub / crates / hub / tests / proposals.rs
42.8 KiB · Rust · last changed Drafts stream into the editor as the assistant writes them 19 days ago source blame history raw
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();
42
    config.auth.push_tokens = vec!["site".into()];
43
    config.registration.mode = "open".into();
44
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
45
    let port = listener.local_addr().unwrap().port();
46
    config.server.base_url = format!("http://127.0.0.1:{port}");
47
    // The stand-in webhook and assistant servers listen on 127.0.0.1 (#219).
48
    config.outbound.allow = vec!["http://127.0.0.1".into()];
49
    let app = oxygenhub::build(config).await.unwrap();
50
    let router = oxygenhub::router(app.clone());
51
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
52
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
53
}
54
55
struct Res {
56
    status: StatusCode,
57
    body: String,
58
    cookie: Option<String>,
59
}
60
61
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: Option<&str>) -> Res {
62
    let mut req = Request::builder().method(method).uri(path);
63
    if let Some(c) = cookie {
64
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
65
    }
66
    if let Some(b) = bearer {
67
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
68
    }
69
    let body = match form {
70
        Some(f) => {
71
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
72
            Body::from(f.to_string())
73
        }
74
        None => Body::empty(),
75
    };
76
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
77
    let status = res.status();
78
    let cookie = res
79
        .headers()
80
        .get(header::SET_COOKIE)
81
        .and_then(|v| v.to_str().ok())
82
        .and_then(|v| v.split(';').next())
83
        .and_then(|kv| kv.split_once('='))
84
        .map(|(_, v)| v.to_string());
85
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
86
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
87
}
88
89
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
90
    send(app, "GET", path, cookie, None, None).await
91
}
92
93
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
94
    send(app, "POST", path, cookie, Some(form), None).await
95
}
96
97
/// POST a proposal action and follow its redirect back to the page, as a
98
/// browser does (actions redirect so a reload never repeats them).
99
async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
100
    let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
101
    if let Some(c) = cookie {
102
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
103
    }
104
    let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap();
105
    assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back");
106
    let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
107
    get(app, &to, cookie).await
108
}
109
110
async fn register(app: &Shared, user: &str) -> String {
111
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
112
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
113
    r.cookie.unwrap()
114
}
115
116
fn csrf_of(page: &str) -> String {
117
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
118
    page[i..].split('"').next().unwrap().to_string()
119
}
120
121
#[tokio::test(flavor = "multi_thread")]
122
async fn propose_review_seal_and_land() {
123
    let h = hub().await;
124
    let app = &h.app;
125
    let andy = register(app, "andy").await;
126
    let bob = register(app, "bob").await;
127
128
    // A repository with main and a feature bookmark, pushed by Andy's client.
129
    let repo = RepoName::parse("andy/demo").unwrap();
130
    app.repos.create(&repo, None).unwrap();
131
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
132
    let work = tempfile::tempdir().unwrap();
133
    let mut client = Repo::init(work.path()).unwrap();
134
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
135
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
136
    client.config.set("remote.origin.token", "site").unwrap();
137
    client.config.set("core.sign", "change").unwrap();
138
    client.save_config().unwrap();
139
    write(work.path(), "a.txt", b"one\ntwo\n");
140
    write(work.path(), ".oxyowners", b"* @andy\n");
141
    client.snapshot("t").unwrap();
142
    let base = client.commit("base", &[]).unwrap();
143
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
144
    client.bookmark_create("feature", Some(&[base])).unwrap();
145
    client.bookmark_switch("feature", false).unwrap();
146
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
147
    client.snapshot("t").unwrap();
148
    let feat = client.commit("add three", &[]).unwrap();
149
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
150
151
    // The compare page offers the proposal to writers only (hub spec §6.3).
152
    let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await;
153
    assert!(cmp.body.contains("href=\"/andy/demo/proposals/new?source=feature&#38;target=main\">Propose this"), "{}", cmp.body);
154
    assert!(!get(app, "/andy/demo/compare/main...feature", Some(&bob)).await.body.contains("Propose this"));
155
    assert!(
156
        !get(app, "/andy/demo/compare/feature...main", Some(&andy)).await.body.contains("Propose this"),
157
        "nothing to propose the other way"
158
    );
159
160
    // Bob previews and opens a proposal feature → main.
161
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
162
    assert_eq!(preview.status, StatusCode::OK);
163
    assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body);
164
    let csrf = csrf_of(&preview.body);
165
    let r =
166
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature"))
167
            .await;
168
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
169
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await;
170
    assert_eq!(page.status, StatusCode::OK);
171
    assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body);
172
    assert!(page.body.contains("Please"));
173
    assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1"));
174
    let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await;
175
    assert!(cmp.body.contains("proposal #1 is open") && !cmp.body.contains("Propose this"), "the open proposal instead");
176
177
    // A comment anchored to line 3 of a.txt lands in the thread on the files tab.
178
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await;
179
    assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body);
180
    let csrf_b = csrf_of(&files.body);
181
    let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await;
182
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
183
    assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body);
184
    let c = db::proposals::comments(&app.db, 1).await.unwrap();
185
    assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced");
186
187
    // Requirements: one sealed approval and code owners.
188
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
189
    let csrf_a = csrf_of(&settings.body);
190
    assert_eq!(
191
        post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1"))
192
            .await
193
            .status,
194
        StatusCode::OK
195
    );
196
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
197
    assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body);
198
    assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body);
199
200
    // Bob cannot approve his own; Andy approves, which is pending until sealed.
201
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body);
202
    let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await;
203
    assert!(r.body.contains("cannot approve your own"), "{}", r.body);
204
    let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await;
205
    assert!(r.body.contains("seal pending"), "{}", r.body);
206
    assert!(r.body.contains("oxy seal --proposal"));
207
208
    // Andy links his key, seals the proposal's change and pushes the seal: the approval counts.
209
    let key = client.config.author_key.unwrap();
210
    let identity = client.identity(&key).unwrap();
211
    db::accounts::link_identity(
212
        &app.db,
213
        db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id,
214
        &key.to_hex(),
215
        &identity.name,
216
        &identity.email,
217
        &identity.encode(),
218
    )
219
    .await
220
    .unwrap();
221
    let seal = client.seal(&[feat]).unwrap();
222
    client.push_seals("origin", &[seal]).unwrap();
223
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
224
    assert!(page.body.contains("approved and sealed"), "{}", page.body);
225
    assert!(page.body.contains("ready to land"), "{}", page.body);
226
    // The API agrees, and names the projected heads for `oxy proposal land`.
227
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
228
    assert_eq!(api.status, StatusCode::OK);
229
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
230
    assert_eq!(v["landable"], true, "{}", api.body);
231
    assert_eq!(v["approvals"][0]["sealed"], true);
232
    assert_eq!(v["projected_heads"][0], feat.to_hex());
233
    assert_eq!(v["changes"][0]["hash"], feat.to_hex());
234
235
    // Land from the hub (no policy): main moves to the projected heads.
236
    let csrf_a = csrf_of(&page.body);
237
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await;
238
    assert!(r.body.contains("Landed."), "{}", r.body);
239
    let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap();
240
    assert_eq!(heads, vec![feat]);
241
    assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1"));
242
    assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)"));
243
    // Landed, it still shows what it landed, by whom and how, and nothing holds it back.
244
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
245
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
246
    assert_eq!(v["state"], "landed", "{}", api.body);
247
    assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body);
248
    assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body);
249
    assert_eq!(v["landed"]["by"], "andy");
250
    assert_eq!(v["landed"]["via"], "web");
251
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
252
    assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body);
253
    // oxygenhub#86: a landed card offering to delete the source bookmark,
254
    // the description edited in place from its ··· menu or the pencil.
255
    assert!(page.body.contains("Proposal landed") && page.body.contains("value=\"feature\""), "{}", page.body);
256
    assert!(page.body.contains("can be safely deleted"), "{}", page.body);
257
    assert!(page.body.contains("class=\"head-pencil\"") && page.body.contains("id=\"edit\"") && page.body.contains("Copy link"));
258
    assert!(!page.body.contains("Edit title, target and description</summary>"), "no disclosure at the bottom");
259
    let csrf = csrf_of(&page.body);
260
    let r = post(app, "/andy/demo/bookmarks/delete", Some(&andy), &format!("_csrf={csrf}&bookmark=feature")).await;
261
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
262
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
263
    assert!(page.body.contains("Proposal landed") && !page.body.contains("can be safely deleted"), "gone, so not offered again");
264
    // Lists by state through the API.
265
    let list = |q: &'static str| async move {
266
        let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await;
267
        serde_json::from_str::<serde_json::Value>(&r.body).unwrap().as_array().unwrap().len()
268
    };
269
    assert_eq!(list("").await, 0, "open ones only by default");
270
    assert_eq!(list("?state=landed").await, 1);
271
    assert_eq!(list("?state=all").await, 1);
272
273
    // A proposal from a fork: a copy of the repository with the same id.
274
    let fork = RepoName::parse("bob/demo").unwrap();
275
    app.repos.create(&fork, Some(client.id().unwrap())).unwrap();
276
    db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap();
277
    db::accounts::set_collaborator(
278
        &app.db,
279
        db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id,
280
        Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id),
281
        None,
282
        "admin",
283
    )
284
    .await
285
    .unwrap();
286
    client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap();
287
    client.config.set("remote.fork.token", "site").unwrap();
288
    client.bookmark_switch("main", false).unwrap();
289
    client.fetch("origin").unwrap();
290
    client.bookmark_set("main", &[feat]).unwrap();
291
    client.bookmark_create("fix", Some(&[feat])).unwrap();
292
    client.bookmark_switch("fix", false).unwrap();
293
    write(work.path(), "b.txt", b"b\n");
294
    client.snapshot("t").unwrap();
295
    let fix = client.commit("add b", &[]).unwrap();
296
    assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok));
297
    let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await;
298
    assert!(preview.body.contains("add b"), "fork preview: {}", preview.body);
299
    let csrf = csrf_of(&preview.body);
300
    let r =
301
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await;
302
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
303
    let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await;
304
    assert!(page.body.contains("add b"), "{}", page.body);
305
    assert!(
306
        app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(),
307
        "the fork's change was copied into the target's store"
308
    );
309
    // Closing and reopening.
310
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body);
311
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await;
312
    assert!(r.body.contains(">closed<"), "{}", r.body);
313
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
314
    assert!(r.body.contains(">open<"), "{}", r.body);
315
}
316
317
/// A stand-in chat-completions endpoint that answers with a fixed review
318
/// and keeps the body of each call.
319
async fn mock_assistant(answer: &'static str) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
320
    use std::sync::{Arc, Mutex};
321
    let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
322
    let s2 = seen.clone();
323
    let router = axum::Router::new().route(
324
        "/v1/chat/completions",
325
        axum::routing::post(move |headers: axum::http::HeaderMap, body: String| {
326
            let s2 = s2.clone();
327
            async move {
328
                let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
329
                s2.lock().unwrap().push(format!("{auth} {body}"));
330
                axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": answer } }] }))
331
            }
332
        }),
333
    );
334
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
335
    let port = listener.local_addr().unwrap().port();
336
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
337
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
338
}
339
340
#[tokio::test(flavor = "multi_thread")]
341
async fn an_assistant_reviews_a_proposal_via_the_person_who_asked() {
342
    let h = hub().await;
343
    let app = &h.app;
344
    let andy = register(app, "andy").await;
345
    let bob = register(app, "bob").await;
346
    let repo = RepoName::parse("andy/demo").unwrap();
347
    app.repos.create(&repo, None).unwrap();
348
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
349
    let work = tempfile::tempdir().unwrap();
350
    let mut client = Repo::init(work.path()).unwrap();
351
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
352
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
353
    client.config.set("remote.origin.token", "site").unwrap();
354
    client.config.set("core.sign", "change").unwrap();
355
    client.save_config().unwrap();
356
    write(work.path(), "a.txt", b"one\ntwo\n");
357
    client.snapshot("t").unwrap();
358
    let base = client.commit("base", &[]).unwrap();
359
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
360
    client.bookmark_create("feature", Some(&[base])).unwrap();
361
    client.bookmark_switch("feature", false).unwrap();
362
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
363
    client.snapshot("t").unwrap();
364
    client.commit("add three", &[]).unwrap();
365
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
366
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
367
    let csrf_b = csrf_of(&preview.body);
368
    let r = post(
369
        app,
370
        "/andy/demo/proposals/new",
371
        Some(&bob),
372
        &format!("_csrf={csrf_b}&title=Add+three&body=Please&target=main&source=feature"),
373
    )
374
    .await;
375
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
376
377
    // Nothing to ask until the site has an assistant.
378
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
379
    assert!(!page.contains("Ask Claude"), "{page}");
380
    let (endpoint, seen) = mock_assistant("**Summary.** Adds a third line.\n\n- a.txt line 3: fine.").await;
381
    let admin = get(app, "/admin", Some(&andy)).await.body;
382
    let csrf_a = csrf_of(&admin);
383
    let ep = endpoint.replace(':', "%3A").replace('/', "%2F");
384
    let r = post(
385
        app,
386
        "/admin/assistants",
387
        Some(&andy),
388
        &format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"),
389
    )
390
    .await;
391
    assert!(r.body.contains("Claude added and answered"), "{}", r.body);
392
    seen.lock().unwrap().clear();
393
    let r =
394
        post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await;
395
    assert!(r.body.contains("Prompt saved."), "{}", r.body);
396
397
    // Claude drafts a description for a proposal not yet opened, and for
398
    // the open one; neither is saved until the person says so.
399
    let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await.body;
400
    assert!(page.contains("Draft with Claude"), "{page}");
401
    let r = post(
402
        app,
403
        "/andy/demo/proposals/new/draft",
404
        Some(&bob),
405
        &format!("_csrf={}&title=Add+three&body=&target=main&source=feature&assistant=Claude", csrf_of(&page)),
406
    )
407
    .await;
408
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
409
    assert!(r.body.contains("rows=\"8\">**Summary.** Adds a third line.") && r.body.contains("value=\"Add three\""), "{}", r.body);
410
    {
411
        let calls = seen.lock().unwrap();
412
        assert_eq!(calls.len(), 1);
413
        assert!(
414
            calls[0].contains("drafting the description") && calls[0].contains("+three") && calls[0].contains("Proposal: Add three"),
415
            "{}",
416
            calls[0]
417
        );
418
    }
419
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
420
    assert!(page.contains("Draft with Claude"), "{page}");
421
    let r =
422
        post(app, "/andy/demo/proposal/1/draft", Some(&bob), &format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page))).await;
423
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
424
    assert!(
425
        r.body.contains("tl-editform open") && r.body.contains("aria-label=\"Description (Markdown)\">**Summary.** Adds a third line."),
426
        "{}",
427
        r.body
428
    );
429
    assert!(r.body.contains("nothing is saved until you update"), "{}", r.body);
430
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "the description is untouched");
431
    assert_eq!(seen.lock().unwrap().len(), 2);
432
    seen.lock().unwrap().clear();
433
434
    // The same as a stream for the page to show as it comes (#248): the
435
    // button says where, and the draft arrives in parts, then whole.
436
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
437
    assert!(page.contains("data-stream=\"/andy/demo/proposal/1/draft/stream\""), "{page}");
438
    let form = format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page));
439
    let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form).await;
440
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
441
    assert!(r.body.contains("event: part") && r.body.contains("event: done"), "{}", r.body);
442
    assert!(r.body.contains("Adds a third line."), "{}", r.body);
443
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "still nothing saved");
444
    let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form.replace("Claude", "Nobody")).await;
445
    assert!(r.body.contains("event: error") && r.body.contains("No assistant called Nobody"), "{}", r.body);
446
    seen.lock().unwrap().clear();
447
448
    // Andy approves; then Bob, the author, asks Claude.
449
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
450
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await;
451
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
452
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
453
    assert!(page.contains("Ask Claude"), "{page}");
454
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=claude", csrf_of(&page))).await;
455
    assert!(r.body.contains("Asked Claude to review"), "{}", r.body);
456
    let mut reviews = vec![];
457
    for _ in 0..100 {
458
        reviews = db::proposals::reviews(&app.db, 1).await.unwrap();
459
        if reviews.iter().any(|r| r.via == "Claude") {
460
            break;
461
        }
462
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
463
    }
464
    let claude = reviews.iter().find(|r| r.via == "Claude").expect("the assistant's review arrived");
465
    assert_eq!((claude.author.as_str(), claude.verdict.as_str()), ("bob", "comment"));
466
    assert!(claude.body.contains("Adds a third line"));
467
    {
468
        let calls = seen.lock().unwrap();
469
        assert_eq!(calls.len(), 1);
470
        let c = &calls[0];
471
        assert!(c.starts_with("Bearer sk-test "), "{c}");
472
        assert!(
473
            c.contains("You are Claude") && c.contains("Review for correctness first."),
474
            "the system prompt and the site's prompt: {c}"
475
        );
476
        assert!(c.contains("Proposal #1: Add three") && c.contains("a.txt (modified)") && c.contains("+three"), "the context: {c}");
477
    }
478
    // Shown as Bob via Claude, with the event; Andy's approval still counts.
479
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
480
    assert!(
481
        page.contains("<strong>bob</strong></a> <span class=\"mute\" aria-hidden=\"true\">↳</span> <span class=\"tag\"")
482
            && page.contains("<strong>Claude</strong>"),
483
        "{page}"
484
    );
485
    assert!(page.contains("asked Claude to review") && page.contains("Adds a third line"), "{page}");
486
    let p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
487
    let loaded = oxygenhub::web::proposals::load(app, &repo, p).await.unwrap();
488
    let v = oxygenhub::web::proposals::verdict(app, &loaded, 1).await.unwrap();
489
    assert!(v.approvals.iter().any(|a| a.account == "andy"), "{:?}", v.approvals.iter().map(|a| &a.account).collect::<Vec<_>>());
490
    // Bob asks Claude for a summary (#190): it comes as a comment via Claude.
491
    assert!(page.contains("Summarise with Claude"), "{page}");
492
    seen.lock().unwrap().clear();
493
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
494
    let r =
495
        act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=Claude&task=summary", csrf_of(&page))).await;
496
    assert!(r.body.contains("Asked Claude for a summary"), "{}", r.body);
497
    let mut comments = vec![];
498
    for _ in 0..100 {
499
        comments = db::proposals::comments(&app.db, 1).await.unwrap();
500
        if comments.iter().any(|c| c.via == "Claude") {
501
            break;
502
        }
503
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
504
    }
505
    let summary = comments.iter().find(|c| c.via == "Claude").expect("the assistant's summary arrived");
506
    assert_eq!(summary.author, "bob");
507
    assert!(summary.body.contains("Adds a third line"));
508
    {
509
        let calls = seen.lock().unwrap();
510
        assert_eq!(calls.len(), 1);
511
        let c = &calls[0];
512
        assert!(
513
            c.contains("called Oxygen")
514
                && c.contains("equivalent of a merge request or pull request")
515
                && c.contains("Please summarise the proposal"),
516
            "{c}"
517
        );
518
        assert!(c.contains("Review for correctness first.") && c.contains("+three"), "the site's prompt and the context: {c}");
519
    }
520
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
521
    assert!(page.contains("asked Claude for a summary") && page.contains("wrote this for bob"), "{page}");
522
    // The API lists the review via Claude and takes requests too.
523
    let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await;
524
    assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body);
525
    // Renaming the assistant renames it on the review and in the events.
526
    let admin = get(app, "/admin", Some(&andy)).await.body;
527
    let r = post(
528
        app,
529
        "/admin/assistants",
530
        Some(&andy),
531
        &format!(
532
            "_csrf={}&action=update&id={}&name=Claude+Code&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1",
533
            csrf_of(&admin),
534
            oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0].id
535
        ),
536
    )
537
    .await;
538
    assert!(r.body.contains("Claude Code updated."), "{}", r.body);
539
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
540
    assert!(page.contains("<strong>Claude Code</strong>") && page.contains("asked Claude Code to review"), "{page}");
541
    assert!(!page.contains("<strong>Claude</strong>"), "{page}");
542
    // A stranger cannot ask; an unknown assistant is refused.
543
    post(app, "/andy/demo/proposal/1/assistant", None, "_csrf=x&assistant=Claude").await;
544
    assert_eq!(seen.lock().unwrap().len(), 1, "no call for a stranger");
545
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
546
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=HAL", csrf_of(&page))).await;
547
    assert!(r.body.contains("No assistant called HAL"), "{}", r.body);
548
}
549
550
/// #141: a proposal whose work reaches its target another way (a client's
551
/// `oxy proposal land`, as a trust policy needs, or a merge pushed by hand)
552
/// is marked landed by that push, not left open with nothing to land.
553
#[tokio::test(flavor = "multi_thread")]
554
async fn a_proposal_landed_by_a_push_is_marked_landed() {
555
    let h = hub().await;
556
    let app = &h.app;
557
    let andy = register(app, "andy").await;
558
    let repo = RepoName::parse("andy/demo").unwrap();
559
    app.repos.create(&repo, None).unwrap();
560
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
561
    let work = tempfile::tempdir().unwrap();
562
    let mut client = Repo::init(work.path()).unwrap();
563
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
564
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
565
    client.config.set("remote.origin.token", "site").unwrap();
566
    client.save_config().unwrap();
567
    write(work.path(), "a.txt", b"one\n");
568
    client.snapshot("t").unwrap();
569
    let base = client.commit("base", &[]).unwrap();
570
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
571
    client.bookmark_create("feature", Some(&[base])).unwrap();
572
    client.bookmark_switch("feature", false).unwrap();
573
    write(work.path(), "a.txt", b"one\ntwo\n");
574
    client.snapshot("t").unwrap();
575
    let feat = client.commit("add two", &[]).unwrap();
576
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
577
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
578
    let csrf = csrf_of(&preview.body);
579
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await;
580
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
581
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state, "open");
582
583
    // The work goes onto main by a push, not by the Land button.
584
    client.bookmark_set("main", &[feat]).unwrap();
585
    assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok));
586
    let mut p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
587
    for _ in 0..100 {
588
        if p.state == "landed" {
589
            break;
590
        }
591
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
592
        p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
593
    }
594
    assert_eq!((p.state.as_str(), p.landed_via.as_str()), ("landed", "push"), "{p:?}");
595
}
596
597
/// #140: a proposal is closed and reopened through the API, as the page's
598
/// buttons do, by its author or someone with triage access.
599
#[tokio::test(flavor = "multi_thread")]
600
async fn a_proposal_is_closed_and_reopened_through_the_api() {
601
    let h = hub().await;
602
    let app = &h.app;
603
    let andy = register(app, "andy").await;
604
    let repo = RepoName::parse("andy/demo").unwrap();
605
    app.repos.create(&repo, None).unwrap();
606
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
607
    let work = tempfile::tempdir().unwrap();
608
    let mut client = Repo::init(work.path()).unwrap();
609
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
610
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
611
    client.config.set("remote.origin.token", "site").unwrap();
612
    client.save_config().unwrap();
613
    write(work.path(), "a.txt", b"one\n");
614
    client.snapshot("t").unwrap();
615
    let base = client.commit("base", &[]).unwrap();
616
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
617
    client.bookmark_create("feature", Some(&[base])).unwrap();
618
    client.bookmark_switch("feature", false).unwrap();
619
    write(work.path(), "a.txt", b"one\ntwo\n");
620
    client.snapshot("t").unwrap();
621
    client.commit("add two", &[]).unwrap();
622
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
623
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
624
    let csrf = csrf_of(&preview.body);
625
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await;
626
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
627
628
    let patch = |json: &'static str, bearer: Option<&'static str>| async move {
629
        let mut req =
630
            Request::builder().method("PATCH").uri("/api/v1/repos/andy/demo/proposals/1").header(header::CONTENT_TYPE, "application/json");
631
        if let Some(b) = bearer {
632
            req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
633
        }
634
        let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(json)).unwrap()).await.unwrap();
635
        let st = res.status();
636
        (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
637
    };
638
    let state = || async { db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state };
639
    // The site token acts for the first administrator, andy.
640
    let (st, body) = patch(r#"{"state":"closed"}"#, Some("site")).await;
641
    assert_eq!(st, StatusCode::OK, "{body}");
642
    assert_eq!(state().await, "closed");
643
    assert_eq!(patch(r#"{"state":"closed"}"#, Some("site")).await.0, StatusCode::OK, "closing a closed proposal changes nothing");
644
    let (st, _) = patch(r#"{"state":"open"}"#, Some("site")).await;
645
    assert_eq!(st, StatusCode::OK);
646
    assert_eq!(state().await, "open");
647
    assert_eq!(patch(r#"{"state":"landed"}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "landing has its own endpoint");
648
    assert_ne!(patch(r#"{"state":"closed"}"#, None).await.0, StatusCode::OK, "not without a token");
649
    assert_eq!(state().await, "open");
650
}
651
652
/// Metal Gear 2's finding: a landed proposal's "Closes #N" closed nothing,
653
/// because only changes a push imports were read. Changes that arrive on
654
/// the default bookmark close issues however they get there.
655
#[tokio::test(flavor = "multi_thread")]
656
async fn closes_in_a_landed_change_closes_the_issue() {
657
    let h = hub().await;
658
    let app = &h.app;
659
    let andy = register(app, "andy").await;
660
    let repo = RepoName::parse("andy/demo").unwrap();
661
    app.repos.create(&repo, None).unwrap();
662
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
663
    let json = |method: &'static str, path: String, body: &'static str| async move {
664
        let res = oxygenhub::router(app.clone())
665
            .oneshot(
666
                Request::builder()
667
                    .method(method)
668
                    .uri(path)
669
                    .header(header::AUTHORIZATION, "Bearer site")
670
                    .header(header::CONTENT_TYPE, "application/json")
671
                    .body(Body::from(body))
672
                    .unwrap(),
673
            )
674
            .await
675
            .unwrap();
676
        let st = res.status();
677
        (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
678
    };
679
    for t in ["one", "two"] {
680
        let body: &'static str = if t == "one" { r#"{"title":"one"}"# } else { r#"{"title":"two"}"# };
681
        let (st, b) = json("POST", "/api/v1/repos/andy/demo/issues".into(), body).await;
682
        assert_eq!(st, StatusCode::CREATED, "{b}");
683
    }
684
    let work = tempfile::tempdir().unwrap();
685
    let mut client = Repo::init(work.path()).unwrap();
686
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
687
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
688
    client.config.set("remote.origin.token", "site").unwrap();
689
    client.save_config().unwrap();
690
    write(work.path(), "a.txt", b"one\n");
691
    client.snapshot("t").unwrap();
692
    let base = client.commit("base", &[]).unwrap();
693
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
694
    client.bookmark_create("feature", Some(&[base])).unwrap();
695
    client.bookmark_switch("feature", false).unwrap();
696
    write(work.path(), "b.txt", b"b\n");
697
    client.snapshot("t").unwrap();
698
    client.commit("add b\n\nCloses #1", &[]).unwrap();
699
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
700
    let state = |n: i64| async move { db::issues::get(&app.db, 1, n).await.unwrap().unwrap().state };
701
    assert_eq!(state(1).await, "open", "a push to another bookmark closes nothing");
702
703
    // Landed on the hub: #1 closes.
704
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
705
    let csrf = csrf_of(&preview.body);
706
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=B&body=&target=main&source=feature")).await;
707
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
708
    let number = db::proposals::list(&app.db, 1, &["open"]).await.unwrap()[0].number;
709
    let (st, b) = json("POST", format!("/api/v1/repos/andy/demo/proposals/{number}/land"), "{}").await;
710
    assert_eq!(st, StatusCode::OK, "{b}");
711
    assert_eq!(state(1).await, "closed", "landing closes what the change says");
712
713
    // A client moving main to a change the hub already holds: #2 closes.
714
    client.bookmark_create("other", Some(&[base])).unwrap();
715
    client.bookmark_switch("other", false).unwrap();
716
    write(work.path(), "c.txt", b"c\n");
717
    client.snapshot("t").unwrap();
718
    let c = client.commit("add c, closes #2", &[]).unwrap();
719
    assert!(client.push_bookmark("origin", "other", false).unwrap().iter().all(|r| r.ok));
720
    assert_eq!(state(2).await, "open");
721
    let (_, list) = json("GET", "/api/v1/repos/andy/demo/bookmarks".into(), "").await;
722
    let list: Vec<serde_json::Value> = serde_json::from_str(&list).unwrap();
723
    let main = list.iter().find(|b| b["name"] == "main").unwrap();
724
    let mut heads: Vec<oxy_core::hash::ChangeHash> =
725
        main["heads"].as_array().unwrap().iter().map(|h| oxy_core::hash::ChangeHash::from_hex(h.as_str().unwrap()).unwrap()).collect();
726
    client.fetch("origin").unwrap();
727
    heads.push(c);
728
    client.bookmark_set("main", &heads).unwrap();
729
    assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok));
730
    let mut s2 = state(2).await;
731
    for _ in 0..100 {
732
        if s2 == "closed" {
733
            break;
734
        }
735
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
736
        s2 = state(2).await;
737
    }
738
    assert_eq!(s2, "closed", "a change arriving by push closes it, though the hub held it already");
739
}
740
741
/// A stand-in that refuses a prompt longer than `limit` bytes as a provider
742
/// does when it overflows the model, and keeps each call's length.
743
async fn mock_limited(limit: usize) -> (String, std::sync::Arc<std::sync::Mutex<Vec<usize>>>) {
744
    use std::sync::{Arc, Mutex};
745
    let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(vec![]));
746
    let s2 = seen.clone();
747
    let router = axum::Router::new().route(
748
        "/v1/chat/completions",
749
        axum::routing::post(move |body: String| {
750
            let s2 = s2.clone();
751
            async move {
752
                s2.lock().unwrap().push(body.len());
753
                if body.len() > limit {
754
                    let msg = format!("This model's maximum context length is {limit} tokens. Please reduce the length of the messages.");
755
                    return (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": { "message": msg } })));
756
                }
757
                (
758
                    StatusCode::OK,
759
                    axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "Small draft." } }] })),
760
                )
761
            }
762
        }),
763
    );
764
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
765
    let port = listener.local_addr().unwrap().port();
766
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
767
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
768
}
769
770
/// A proposal too large for the model (oxygenhub#248): the provider refuses
771
/// it, the hub asks again with a third of the room, and the answer comes.
772
#[tokio::test(flavor = "multi_thread")]
773
async fn a_proposal_too_large_for_the_model_is_asked_again_smaller() {
774
    let h = hub().await;
775
    let app = &h.app;
776
    let andy = register(app, "andy").await;
777
    let repo = RepoName::parse("andy/demo").unwrap();
778
    app.repos.create(&repo, None).unwrap();
779
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
780
    let work = tempfile::tempdir().unwrap();
781
    let mut client = Repo::init(work.path()).unwrap();
782
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
783
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
784
    client.config.set("remote.origin.token", "site").unwrap();
785
    client.save_config().unwrap();
786
    write(work.path(), "a.txt", b"one\n");
787
    client.snapshot("t").unwrap();
788
    let base = client.commit("base", &[]).unwrap();
789
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
790
    client.bookmark_create("feature", Some(&[base])).unwrap();
791
    client.bookmark_switch("feature", false).unwrap();
792
    // A big change: 200 lines of 60 characters, about 12 KB of diff.
793
    let big: String = (0..200).map(|i| format!("{i:04} {}\n", "x".repeat(55))).collect();
794
    write(work.path(), "big.txt", big.as_bytes());
795
    client.snapshot("t").unwrap();
796
    client.commit("a big file", &[]).unwrap();
797
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
798
799
    let (endpoint, seen) = mock_limited(6_000).await;
800
    let admin = get(app, "/admin", Some(&andy)).await.body;
801
    let ep = endpoint.replace(':', "%3A").replace('/', "%2F");
802
    let r = post(
803
        app,
804
        "/admin/assistants",
805
        Some(&andy),
806
        &format!(
807
            "_csrf={}&action=add&name=Small&endpoint={ep}&api=chat-completions&model=small-1&token=sk-test&enabled=1&budget=12000",
808
            csrf_of(&admin)
809
        ),
810
    )
811
    .await;
812
    assert!(r.body.contains("Small added and answered"), "{}", r.body);
813
    seen.lock().unwrap().clear();
814
    let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await.body;
815
    let r = post(
816
        app,
817
        "/andy/demo/proposals/new/draft",
818
        Some(&andy),
819
        &format!("_csrf={}&title=Big&body=&target=main&source=feature&assistant=Small", csrf_of(&page)),
820
    )
821
    .await;
822
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
823
    assert!(r.body.contains("Small draft."), "{}", r.body);
824
    let calls = seen.lock().unwrap().clone();
825
    assert_eq!(calls.len(), 2, "refused once, then asked smaller: {calls:?}");
826
    assert!(calls[0] > 6_000 && calls[1] <= 6_000, "{calls:?}");
827
}