OXYGENHUB
# 1c52d940b4a0 oxygenhub / crates / hub / tests / issues.rs
89.9 KiB · Rust · last changed Iterations on project boards: a named span of days with a burn-up of cards done; closing one archives its Done column's cards under it, its page lists them (closes #343) 9 days ago source blame history raw
1
//! Milestone 0.4 end to end: issues with labels, assignees and
2
//! milestones, cross references, closing keywords on landing, the inbox,
3
//! watching, mentions and search.
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
    // The first account's setup code (oxygenhub#245).
43
    config.registration.setup_code = "test-setup".into();
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}");
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
    location: Option<String>,
60
}
61
62
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
63
    let mut req = Request::builder().method(method).uri(path);
64
    if let Some(c) = cookie {
65
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
66
    }
67
    let body = match form {
68
        Some(f) => {
69
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
70
            Body::from(f.to_string())
71
        }
72
        None => Body::empty(),
73
    };
74
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
75
    let status = res.status();
76
    let cookie = res
77
        .headers()
78
        .get(header::SET_COOKIE)
79
        .and_then(|v| v.to_str().ok())
80
        .and_then(|v| v.split(';').next())
81
        .and_then(|kv| kv.split_once('='))
82
        .map(|(_, v)| v.to_string());
83
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
84
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
85
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
86
}
87
88
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
89
    send(app, "GET", path, cookie, None).await
90
}
91
92
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
93
    send(app, "POST", path, cookie, Some(form)).await
94
}
95
96
async fn register(app: &Shared, user: &str) -> String {
97
    let r = post(
98
        app,
99
        "/register",
100
        None,
101
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
102
    )
103
    .await;
104
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
105
    r.cookie.unwrap()
106
}
107
108
/// A repository settings form's answer (#278): a redirect to the page that
109
/// holds the form, followed.
110
async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res {
111
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
112
    let to = r.location.expect("a redirect");
113
    assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}");
114
    get(app, &to, cookie).await
115
}
116
117
fn csrf_of(page: &str) -> String {
118
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
119
    page[i..].split('"').next().unwrap().to_string()
120
}
121
122
#[tokio::test(flavor = "multi_thread")]
123
async fn issues_notifications_and_search() {
124
    let h = hub().await;
125
    let app = &h.app;
126
    let andy = register(app, "andy").await;
127
    let bob = register(app, "bob").await;
128
    let repo = RepoName::parse("andy/demo").unwrap();
129
    app.repos.create(&repo, None).unwrap();
130
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
131
132
    // Labels and a milestone, by the owner.
133
    let labels_page = get(app, "/andy/demo/labels", Some(&andy)).await;
134
    let csrf_a = csrf_of(&labels_page.body);
135
    assert!(post(app, "/andy/demo/labels", Some(&andy), &format!("_csrf={csrf_a}&name=bug&colour=%23ff0000&description=Something+broke"))
136
        .await
137
        .body
138
        .contains("Saved bug"));
139
    assert!(post(app, "/andy/demo/milestones", Some(&andy), &format!("_csrf={csrf_a}&title=v1&description=First&due=2027-01-01"))
140
        .await
141
        .body
142
        .contains("v1"));
143
    assert_eq!(get(app, "/andy/demo/labels", Some(&bob)).await.status, StatusCode::OK, "readers see labels");
144
    assert!(!get(app, "/andy/demo/labels", Some(&bob)).await.body.contains("Add or change"), "but cannot edit them");
145
146
    // Bob watches the repository, then Andy opens an issue mentioning @bob: Bob is told twice at most once.
147
    let overview = get(app, "/andy/demo", Some(&bob)).await;
148
    let csrf_b = csrf_of(&overview.body);
149
    assert_eq!(post(app, "/andy/demo/watch", Some(&bob), &format!("_csrf={csrf_b}")).await.status, StatusCode::SEE_OTHER);
150
    assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("UNWATCH"));
151
    let new_page = get(app, "/andy/demo/issues/new", Some(&andy)).await;
152
    let csrf_a = csrf_of(&new_page.body);
153
    let r = post(
154
        app,
155
        "/andy/demo/issues/new",
156
        Some(&andy),
157
        &format!("_csrf={csrf_a}&title=Crash+on+start&body=cc+%40bob+see+%23999+and+deadbeefcafe1234"),
158
    )
159
    .await;
160
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
161
    assert_eq!(r.location.as_deref(), Some("/andy/demo/issue/1"));
162
    let page = get(app, "/andy/demo/issue/1", Some(&bob)).await;
163
    assert!(page.body.contains("Crash on start"));
164
    assert!(page.body.contains("href=\"/bob\""), "mention linked: {}", page.body);
165
    assert!(page.body.contains("/andy/demo/change/deadbeefcafe1234"), "hash linked: {}", page.body);
166
    assert!(page.body.contains("#999") && !page.body.contains("issue/999"), "unknown numbers are not linked");
167
    let unread = db::notify::unread(&app.db, db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id).await.unwrap();
168
    assert_eq!(unread, 1, "one notification, not one per reason");
169
    // The inbox's thread counts it unread (the SQL is portable now:
170
    // oxygenhub#211).
171
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
172
    let filter = db::notify::Filter { done: false, unread_only: false, reason: None, kind: None, repo_id: None };
173
    let t = db::notify::threads(&app.db, bob_id, &filter, 10).await.unwrap();
174
    assert_eq!((t[0].count, t[0].unread), (1, 1));
175
    let unread_only = db::notify::Filter { unread_only: true, ..filter.clone() };
176
    assert_eq!(db::notify::threads(&app.db, bob_id, &unread_only, 10).await.unwrap().len(), 1);
177
    let inbox = get(app, "/notifications", Some(&bob)).await;
178
    assert!(inbox.body.contains("andy/demo#1: Crash on start"), "{}", inbox.body);
179
    assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("class=\"count\">1<"), "bell shows the count");
180
181
    // Triage: labels, assignee, milestone, pin, lock.
182
    let page = get(app, "/andy/demo/issue/1", Some(&andy)).await;
183
    let csrf_a = csrf_of(&page.body);
184
    let label_id: i64 = sqlx::query_scalar("SELECT id FROM labels WHERE name = 'bug'").fetch_one(&app.db).await.unwrap();
185
    let r = post(app, "/andy/demo/issue/1/labels", Some(&andy), &format!("_csrf={csrf_a}&label={label_id}")).await;
186
    assert!(r.body.contains(">bug<"), "{}", r.body);
187
    let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&username=bob&action=add")).await;
188
    assert!(r.body.contains("<li><a href=\"/bob\"><img class=\"avatar xs\""), "{}", r.body);
189
    // oxygenhub#82: the sidebar's menu sets the whole list at once.
190
    assert!(r.body.contains("Assign people to this issue") && r.body.contains("data-menu-filter"), "the assignees menu");
191
    let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=andy&username=bob")).await;
192
    let assigned = |db: oxygenhub::db::Pool| async move {
193
        let id: i64 = sqlx::query_scalar("SELECT id FROM issues WHERE number = 1").fetch_one(&db).await.unwrap();
194
        let mut a = db::issues::assignees(&db, id).await.unwrap();
195
        a.sort();
196
        a
197
    };
198
    assert_eq!(assigned(app.db.clone()).await, ["andy", "bob"], "{}", r.status);
199
    post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=bob")).await;
200
    assert_eq!(assigned(app.db.clone()).await, ["bob"], "set takes off who is not listed");
201
    let ms_id: i64 = sqlx::query_scalar("SELECT id FROM milestones WHERE title = 'v1'").fetch_one(&app.db).await.unwrap();
202
    let r = post(app, "/andy/demo/issue/1/milestone", Some(&andy), &format!("_csrf={csrf_a}&milestone={ms_id}")).await;
203
    assert!(r.body.contains(">v1<"), "{}", r.body);
204
    assert!(post(app, "/andy/demo/issue/1/state", Some(&andy), &format!("_csrf={csrf_a}&action=pin")).await.body.contains("Unpin issue"));
205
    assert_eq!(
206
        post(
207
            app,
208
            "/andy/demo/issue/1/state",
209
            Some(&bob),
210
            &format!("_csrf={}&action=lock", csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body))
211
        )
212
        .await
213
        .status,
214
        StatusCode::NOT_FOUND,
215
        "bob cannot lock"
216
    );
217
    // Filters on the list.
218
    let list = get(app, "/andy/demo/issues?q=label:bug+assignee:bob+milestone:v1", None).await;
219
    assert!(list.body.contains("Crash on start"), "{}", list.body);
220
    assert!(!get(app, "/andy/demo/issues?q=label:feature", None).await.body.contains("Crash on start"));
221
    assert!(get(app, "/andy/demo/milestones", None).await.body.contains("1 open, 0 closed"));
222
223
    // Bob comments and closes; the author is told; search finds the comment.
224
    let csrf_b = csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body);
225
    let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=Reproduced+with+the+flux+capacitor")).await;
226
    assert!(r.body.contains("flux capacitor"), "{}", r.body);
227
    assert!(get(app, "/notifications", Some(&andy)).await.body.contains("andy/demo#1"));
228
    let s = get(app, "/search?q=capacitor", None).await;
229
    assert!(s.body.contains("Crash on start") && s.body.contains("<mark>capacitor</mark>"), "{}", s.body);
230
    let s = get(app, "/search?q=demo", None).await;
231
    assert!(s.body.contains("/andy/demo\""), "repository hit: {}", s.body);
232
    // Bob is not triage, but as an assignee of an open issue he can comment-and-close? No: only the author or triage.
233
    let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=closing&close=completed")).await;
234
    assert!(r.body.contains(">open<") || r.body.contains("head\">open"), "still open: {}", r.body);
235
    let r = post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=fixed&close=completed")).await;
236
    assert!(r.body.contains("closed"), "{}", r.body);
237
    assert!(get(app, "/andy/demo/issues?state=closed", None).await.body.contains("Crash on start"));
238
239
    // A second issue closed by a change message pushed to the default bookmark.
240
    let csrf_a = csrf_of(&get(app, "/andy/demo/issues/new", Some(&andy)).await.body);
241
    assert_eq!(
242
        post(app, "/andy/demo/issues/new", Some(&andy), &format!("_csrf={csrf_a}&title=Typo&body=")).await.location.as_deref(),
243
        Some("/andy/demo/issue/2")
244
    );
245
    let work = tempfile::tempdir().unwrap();
246
    let mut client = Repo::init(work.path()).unwrap();
247
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
248
    client.config.set("remote.origin.token", "site").unwrap();
249
    client.save_config().unwrap();
250
    write(work.path(), "a.txt", b"a\n");
251
    client.snapshot("t").unwrap();
252
    client.commit("Fix the typo\n\nCloses #2", &[]).unwrap();
253
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
254
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
255
    let issue2 = db::issues::get(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, 2).await.unwrap().unwrap();
256
    assert_eq!(issue2.state, "closed", "closed by the landed change");
257
    assert!(get(app, "/andy/demo/issue/2", None).await.body.contains("closed by change"));
258
259
    // Numbers are shared: the next proposal is #3, and /issue/3 redirects to it.
260
    client.bookmark_create("feat", None).unwrap();
261
    client.bookmark_switch("feat", false).unwrap();
262
    write(work.path(), "b.txt", b"b\n");
263
    client.snapshot("t").unwrap();
264
    client.commit("feat", &[]).unwrap();
265
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
266
    let csrf_a = csrf_of(&get(app, "/andy/demo/proposals/new?source=feat&target=main", Some(&andy)).await.body);
267
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf_a}&title=Feat&target=main&source=feat")).await;
268
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"), "{}", r.body);
269
    assert_eq!(get(app, "/andy/demo/issue/3", None).await.location.as_deref(), Some("/andy/demo/proposal/3"));
270
    // A comment on the issue referencing #3 links to the proposal.
271
    let r = post(app, "/andy/demo/issue/2/comment", Some(&andy), &format!("_csrf={csrf_a}&body=see+%233")).await;
272
    assert!(r.body.contains("href=\"/andy/demo/proposal/3\""), "{}", r.body);
273
    // `#N` in a pushed change's message lands on those timelines (hub spec §8).
274
    client.bookmark_switch("main", false).unwrap();
275
    write(work.path(), "c.txt", b"c\n");
276
    client.snapshot("t").unwrap();
277
    let r = client.commit("Tidy up (refs #1, see #3)", &[]).unwrap();
278
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
279
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
280
    let short = &r.to_hex()[..12];
281
    let one = get(app, "/andy/demo/issue/1", None).await.body;
282
    assert!(
283
        one.contains("referenced by change") && one.contains(&format!("href=\"/andy/demo/change/{short}\"")) && one.contains(" on main"),
284
        "{one}"
285
    );
286
    let three = get(app, "/andy/demo/proposal/3", Some(&andy)).await.body;
287
    assert!(three.contains("referenced by change") && three.contains(short), "proposals too");
288
    assert!(!get(app, "/andy/demo/issue/2", None).await.body.contains("referenced by change"), "a closing change records only that");
289
    // #318: the same change pushed on another bookmark is no new reference.
290
    client.bookmark_create("again", None).unwrap();
291
    assert!(client.push_bookmark("origin", "again", false).unwrap().iter().all(|r| r.ok));
292
    tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
293
    let recorded: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_events WHERE event = 'referenced by change' AND detail LIKE $1")
294
        .bind(format!("{short} %"))
295
        .fetch_one(&app.db)
296
        .await
297
        .unwrap();
298
    assert_eq!(recorded, 1, "once per change, whichever bookmarks it is on");
299
300
    // #292: many references on one day are one box, wrapping, the newest
301
    // day's open; a lone one stays a line.
302
    let issue1 = db::issues::get(&app.db, repo_id, 1).await.unwrap().unwrap();
303
    let day = 1_792_540_800_000i64; // 21 Oct 2026, 00:00 UTC
304
    for (i, at) in [day + 1_000, day + 2_000, day + 3_000, day - 86_400_000].into_iter().enumerate() {
305
        sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES ($1, $2, 'andy', 'referenced by change', $3)")
306
            .bind(issue1.id)
307
            .bind(at)
308
            .bind(format!("{:012x} on main", 0xabc000 + i))
309
            .execute(&app.db)
310
            .await
311
            .unwrap();
312
    }
313
    // One recorded again for another bookmark before #318: shown once.
314
    sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES ($1, $2, 'andy', 'referenced by change', $3)")
315
        .bind(issue1.id)
316
        .bind(day + 4_000)
317
        .bind(format!("{:012x} on develop", 0xabc000))
318
        .execute(&app.db)
319
        .await
320
        .unwrap();
321
    let one = get(app, "/andy/demo/issue/1", None).await.body;
322
    assert!(one.contains("referenced by 3 changes on 21 Oct 2026"), "{one}");
323
    assert_eq!(one.matches(&format!("change/{:012x}\"", 0xabc000)).count(), 1, "each change once");
324
    assert!(!one.contains("mute timeline"), "the events have no rule of their own (#318)");
325
    assert!(one.contains("refs-day\" open><summary>referenced by 3 changes"), "the newest box is open: {one}");
326
    assert!(!one.contains("changes on 20 Oct 2026"), "a lone reference is a line");
327
    assert!(one.contains(&format!("href=\"/andy/demo/change/{short}\"")), "today's own stays");
328
329
    // Bob takes part in #1 (mentioned, assigned, commented) and only watches #3.
330
    let part = get(app, "/notifications?reason=participating", Some(&bob)).await.body;
331
    assert!(part.contains("andy/demo#1") && !part.contains("andy/demo#3: Feat"), "{part}");
332
    let watch = get(app, "/notifications?reason=watching", Some(&bob)).await.body;
333
    assert!(watch.contains("andy/demo#3: Feat") && !watch.contains("Crash on start"), "{watch}");
334
    let props = get(app, "/notifications?kind=proposal", Some(&bob)).await.body;
335
    assert!(props.contains("andy/demo#3: Feat") && !props.contains("andy/demo#1"), "kind filter");
336
    assert!(get(app, "/notifications?repo=andy/demo", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
337
    assert!(get(app, "/notifications?repo=nobody/none", Some(&bob)).await.body.contains("Nothing matches these filters"));
338
    // One row per thread, however many events it had.
339
    let inbox = get(app, "/notifications", Some(&bob)).await.body;
340
    assert_eq!(inbox.matches("data-row-thread=\"/andy/demo/issue/1\"").count(), 1, "{inbox}");
341
    assert!(inbox.contains(" updates"), "the count of a busy thread");
342
    // Opening a thread marks it read and goes there.
343
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
344
    let newest: i64 = sqlx::query_scalar(
345
        "SELECT id FROM notifications WHERE account_id = $1 AND thread = '/andy/demo/proposal/3' ORDER BY id DESC LIMIT 1",
346
    )
347
    .bind(bob_id)
348
    .fetch_one(&app.db)
349
    .await
350
    .unwrap();
351
    let before = db::notify::unread(&app.db, bob_id).await.unwrap();
352
    let r = get(app, &format!("/notifications/open/{newest}"), Some(&bob)).await;
353
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"));
354
    assert!(db::notify::unread(&app.db, bob_id).await.unwrap() < before);
355
    assert_eq!(get(app, &format!("/notifications/open/{newest}"), Some(&andy)).await.status, StatusCode::NOT_FOUND, "only your own");
356
    // Done sets a thread aside until something new happens in it.
357
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
358
    let r = post(
359
        app,
360
        "/notifications/thread",
361
        Some(&bob),
362
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=done&back=%2Fnotifications"),
363
    )
364
    .await;
365
    assert_eq!(r.location.as_deref(), Some("/notifications"));
366
    assert!(!get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"));
367
    assert!(get(app, "/notifications?view=done", Some(&bob)).await.body.contains("Crash on start"));
368
    post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=one+more+thing")).await;
369
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"), "back with the new comment");
370
    let r = post(
371
        app,
372
        "/notifications/thread",
373
        Some(&bob),
374
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=read&back=https%3A%2F%2Fevil.example"),
375
    )
376
    .await;
377
    assert_eq!(r.location.as_deref(), Some("/notifications"), "only back to the inbox");
378
379
    // Bob (watcher) heard about the proposal; the inbox marks all read.
380
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
381
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
382
    let r = post(app, "/notifications/read", Some(&bob), &format!("_csrf={csrf_b}")).await;
383
    assert!(r.body.contains("0 unread"), "{}", r.body);
384
    // Email preference and the unsubscribe link.
385
    let r = post(app, "/settings/notifications", Some(&bob), &format!("_csrf={csrf_b}&email=1")).await;
386
    assert!(r.body.contains("Email notifications are on"));
387
    let key: String = sqlx::query_scalar("SELECT unsubscribe_key FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
388
    assert_eq!(get(app, &format!("/settings/notifications?unsubscribe={key}"), None).await.status, StatusCode::OK);
389
    let on: bool = sqlx::query_scalar("SELECT email_notifications FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap();
390
    assert!(!on);
391
}
392
393
#[tokio::test(flavor = "multi_thread")]
394
async fn dashboard_shows_what_needs_the_viewer() {
395
    let h = hub().await;
396
    let app = &h.app;
397
    let andy = register(app, "andy").await;
398
    let bob = register(app, "bob").await;
399
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
400
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
401
        db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
402
    }
403
    let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
404
    let secret = db::repos::get(&app.db, "andy", "secret").await.unwrap().unwrap();
405
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
406
    for (repo, title) in [(demo.id, "Faster startup"), (secret.id, "Hidden work")] {
407
        let p = db::proposals::create(
408
            &app.db,
409
            db::proposals::NewProposal {
410
                repo_id: repo,
411
                title,
412
                body: "",
413
                author_id: Some(bob_id),
414
                author: "bob",
415
                draft: false,
416
                target: "main",
417
                source_kind: "bookmark",
418
                source_repo_id: Some(repo),
419
                source_bookmark: "fast",
420
                source_changes: "",
421
            },
422
        )
423
        .await
424
        .unwrap();
425
        db::proposals::add_event(&app.db, p.id, "bob", "opened", "").await.unwrap();
426
    }
427
428
    // Signed out, `/` is still the repository list.
429
    let anon = get(app, "/", None).await;
430
    assert!(anon.body.contains("andy /") && !anon.body.contains("Dashboard <small>"), "{}", anon.body);
431
432
    // Andy owns both repositories: both proposals wait for his review.
433
    let page = get(app, "/", Some(&andy)).await;
434
    assert_eq!(page.status, StatusCode::OK);
435
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">2</span>"), "{}", page.body);
436
    assert!(page.body.contains("/andy/demo/proposal/1") && page.body.contains("/andy/secret/proposal/1"));
437
    assert!(page.body.contains("Your repositories"));
438
    assert!(page.body.contains("opened proposal"), "activity feed: {}", page.body);
439
440
    // Your sky (#157, docs/backgrounds.md §5): the canvas, and its data from
441
    // the database: his repositories and their events, safe in a script.
442
    assert!(page.body.contains(r#"<canvas id="backdrop" aria-hidden="true">"#));
443
    let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
444
    let data: serde_json::Value = serde_json::from_str(&page.body[start..start + page.body[start..].find("</script>").unwrap()]).unwrap();
445
    let repos: Vec<&str> = data["repos"].as_array().unwrap().iter().filter_map(|r| r.as_str()).collect();
446
    assert!(repos.contains(&"andy/demo") && repos.contains(&"andy/secret"), "{data}");
447
    let events = data["events"].as_array().unwrap();
448
    assert!(events.iter().any(|e| e[1] == 1), "the proposals opened are marks: {data}");
449
    assert!(events.iter().all(|e| e[0].as_u64().unwrap() < repos.len() as u64));
450
    // His page is his alone: no shared cache may keep it (#157).
451
    let res = oxygenhub::router(app.clone())
452
        .oneshot(Request::builder().uri("/").header(header::COOKIE, format!("oxyhub={andy}")).body(Body::empty()).unwrap())
453
        .await
454
        .unwrap();
455
    assert_eq!(res.headers()[header::CACHE_CONTROL], "private, no-store");
456
    // The mark on sign in; adrift on a page not found, not on a refusal.
457
    assert!(get(app, "/login", None)
458
        .await
459
        .body
460
        .contains(r#"<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>"#));
461
    let lost = get(app, "/no/such/page/here", None).await;
462
    assert_eq!(lost.status, StatusCode::NOT_FOUND);
463
    assert!(lost.body.contains(r#"{"scene":"adrift"}"#));
464
    // Signed out, `/` has the hub's sky (#165): public repositories only.
465
    let sky_of = |body: &str| {
466
        let start = body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
467
        body[start..start + body[start..].find("</script>").unwrap()].to_string()
468
    };
469
    let hub_sky = sky_of(&anon.body);
470
    assert!(hub_sky.contains("andy/demo") && !hub_sky.contains("andy/secret"), "{hub_sky}");
471
    // Explore's sky is the hub's, public only, even for the owner of a
472
    // private repository; an owner's listing has none.
473
    let explore = get(app, "/explore", Some(&andy)).await;
474
    assert!(explore.body.contains("andy/secret"), "the list shows him his private repository");
475
    let hub_sky = sky_of(&explore.body);
476
    assert!(hub_sky.contains("andy/demo") && !hub_sky.contains("andy/secret"), "{hub_sky}");
477
    assert!(!get(app, "/andy", None).await.body.contains("backdrop-data"));
478
    // The engine is served as WebAssembly.
479
    let res = oxygenhub::router(app.clone())
480
        .oneshot(Request::builder().uri("/static/backdrop/backdrop_bg.wasm").body(Body::empty()).unwrap())
481
        .await
482
        .unwrap();
483
    assert_eq!(res.headers()[header::CONTENT_TYPE], "application/wasm");
484
    assert!(res.into_body().collect().await.unwrap().to_bytes().starts_with(b"\0asm"));
485
    // Live arrivals (#172): what happened since, by the sky's own rule.
486
    assert!(sky_of(&page.body).contains(r#""live":"mine""#) && hub_sky.contains(r#""live":"hub""#));
487
    let since = db::now_ms() - 3_600_000;
488
    let arrivals = |res: Res| -> Vec<String> {
489
        assert_eq!(res.status, StatusCode::OK, "{}", res.body);
490
        let v: serde_json::Value = serde_json::from_str(&res.body).unwrap();
491
        v["events"].as_array().unwrap().iter().map(|e| e[0].as_str().unwrap().to_string()).collect()
492
    };
493
    let mine = arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&andy)).await);
494
    assert!(mine.iter().any(|r| r == "andy/demo") && mine.iter().any(|r| r == "andy/secret"), "{mine:?}");
495
    let hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), Some(&andy)).await);
496
    assert!(hub.iter().any(|r| r == "andy/demo") && !hub.iter().any(|r| r == "andy/secret"), "the hub's sky is public only: {hub:?}");
497
    let anon_hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), None).await);
498
    assert!(!anon_hub.iter().any(|r| r == "andy/secret"), "{anon_hub:?}");
499
    assert!(arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), None).await).is_empty(), "signed out has no `mine`");
500
    assert!(
501
        arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&bob)).await).is_empty(),
502
        "Bob's sky has none of Andy's"
503
    );
504
    assert!(
505
        arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={}", db::now_ms()), Some(&andy)).await).is_empty(),
506
        "nothing since now"
507
    );
508
509
    // Once he reviews one, it leaves his queue.
510
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
511
    let p = db::proposals::get(&app.db, demo.id, 1).await.unwrap().unwrap();
512
    db::proposals::add_review(&app.db, p.id, andy_id, "andy", "approve", "").await.unwrap();
513
    assert!(get(app, "/", Some(&andy)).await.body.contains("Waiting for your review</h2><span class=\"count-chip\">1</span>"));
514
515
    // Bob sees his own proposal on the public repository, never the private one's.
516
    let page = get(app, "/", Some(&bob)).await;
517
    // His sky holds only his own repositories: never Andy's, public or
518
    // private, and nothing of the private one (#157).
519
    let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
520
    let sky = &page.body[start..start + page.body[start..].find("</script>").unwrap()];
521
    assert!(!sky.contains("andy/secret") && !sky.contains("andy/demo"), "{sky}");
522
    assert!(page.body.contains("Your proposals</h2><span class=\"count-chip\">1</span>"), "{}", page.body);
523
    assert!(!page.body.contains("Hidden work"));
524
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">0</span>"));
525
    assert!(page.body.contains("Faster startup"));
526
    let feed = &page.body[page.body.find("Recent activity").unwrap()..];
527
    assert!(!feed.contains("andy/secret"), "private activity leaked: {feed}");
528
529
    // Explore still lists every visible repository.
530
    assert!(get(app, "/explore", Some(&bob)).await.body.contains("andy /"));
531
}
532
533
#[tokio::test(flavor = "multi_thread")]
534
async fn empty_repository_shows_a_quickstart() {
535
    let h = hub().await;
536
    let app = &h.app;
537
    let andy = register(app, "andy").await;
538
    let bob = register(app, "bob").await;
539
    app.repos.create(&RepoName::parse("andy/fresh").unwrap(), None).unwrap();
540
    db::repos::create(&app.db, "andy", "fresh", "", hub_api::Visibility::Public).await.unwrap();
541
542
    let page = get(app, "/andy/fresh", Some(&andy)).await;
543
    assert!(page.body.contains("This repository is empty"), "{}", page.body);
544
    let clone_url = format!("{}/andy/fresh", h.base);
545
    assert!(page.body.contains(&format!("oxy remote add hub {clone_url}")), "{}", page.body);
546
    assert!(page.body.contains("oxy config remote.hub.token YOUR-TOKEN"));
547
    assert!(page.body.contains("oxy push hub"));
548
    assert!(
549
        page.body.contains("mkdir fresh &amp;&amp; cd fresh") || page.body.contains("mkdir fresh &#38;&#38; cd fresh"),
550
        "{}",
551
        page.body
552
    );
553
    assert!(page.body.contains("oxy git import --branch main"));
554
    assert!(!page.body.contains("oxyhub-ca.pem"), "no certificate step without a self-signed certificate");
555
556
    // A reader without write access is not told to push.
557
    let page = get(app, "/andy/fresh", Some(&bob)).await;
558
    assert!(page.body.contains("Nothing has been pushed yet") && !page.body.contains("oxy push hub"), "{}", page.body);
559
560
    // After a first push the quickstart gives way to the tree.
561
    let work = tempfile::tempdir().unwrap();
562
    let mut client = Repo::init(work.path()).unwrap();
563
    client.config.set("remote.hub.url", &clone_url).unwrap();
564
    client.config.set("remote.hub.token", "site").unwrap();
565
    client.save_config().unwrap();
566
    write(work.path(), "README.md", b"# fresh\n");
567
    client.commit("First change", &[]).unwrap();
568
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
569
    let page = get(app, "/andy/fresh", Some(&andy)).await;
570
    assert!(!page.body.contains("This repository is empty") && page.body.contains("README.md"), "{}", page.body);
571
}
572
573
#[tokio::test(flavor = "multi_thread")]
574
async fn theme_is_chosen_in_settings_and_remembered_in_a_cookie() {
575
    let h = hub().await;
576
    let app = &h.app;
577
    let andy = register(app, "andy").await;
578
    let page = get(app, "/settings", Some(&andy)).await;
579
    assert!(page.body.contains("Appearance"));
580
    assert!(page.body.contains(r#"value="light" checked"#), "light by default: {}", page.body);
581
    assert!(page.body.contains("data-theme-toggle"), "the header has the switch");
582
    let csrf = csrf_of(&page.body);
583
    let r = post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=dark")).await;
584
    assert_eq!(r.status, StatusCode::SEE_OTHER);
585
    assert_eq!(r.cookie.as_deref(), Some("dark"));
586
    assert_eq!(r.location.as_deref(), Some("/settings"));
587
    let page = send_with_theme(app, &andy, "dark").await;
588
    assert!(page.contains(r#"value="dark" checked"#), "the form shows the cookie's theme");
589
    assert_eq!(post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=purple")).await.status, StatusCode::BAD_REQUEST);
590
591
    // The background (docs/backgrounds.md §7): moving by default, kept in
592
    // its own cookie beside the theme.
593
    assert!(get(app, "/settings", Some(&andy)).await.body.contains(r#"name="backdrop" value="moving" checked"#));
594
    let res = oxygenhub::router(app.clone())
595
        .oneshot(
596
            Request::builder()
597
                .method("POST")
598
                .uri("/settings/appearance")
599
                .header(header::COOKIE, format!("oxyhub={andy}"))
600
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
601
                .body(Body::from(format!("_csrf={csrf}&theme=light&backdrop=still")))
602
                .unwrap(),
603
        )
604
        .await
605
        .unwrap();
606
    let cookies: Vec<&str> = res.headers().get_all(header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).collect();
607
    assert!(
608
        cookies.iter().any(|c| c.starts_with("theme=light;")) && cookies.iter().any(|c| c.starts_with("backdrop=still;")),
609
        "{cookies:?}"
610
    );
611
    assert_eq!(
612
        post(app, "/settings/appearance", Some(&andy), &format!("_csrf={csrf}&theme=light&backdrop=wild")).await.status,
613
        StatusCode::BAD_REQUEST
614
    );
615
    assert_eq!(post(app, "/settings/appearance", Some(&andy), "_csrf=nope&theme=light").await.status, StatusCode::BAD_REQUEST);
616
}
617
618
async fn send_with_theme(app: &Shared, session: &str, theme: &str) -> String {
619
    let req =
620
        Request::builder().uri("/settings").header(header::COOKIE, format!("oxyhub={session}; theme={theme}")).body(Body::empty()).unwrap();
621
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
622
    String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()
623
}
624
625
#[tokio::test(flavor = "multi_thread")]
626
async fn profile_and_organisation_pages() {
627
    let h = hub().await;
628
    let app = &h.app;
629
    let andy = register(app, "andy").await;
630
    let bob = register(app, "bob").await;
631
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
632
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
633
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
634
    db::repos::create(&app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap();
635
    app.repos.create(&RepoName::parse("andy/hidden").unwrap(), None).unwrap();
636
    db::repos::create(&app.db, "andy", "hidden", "", hub_api::Visibility::Private).await.unwrap();
637
638
    // Andy fills in his profile; a script URL and markup are refused or escaped.
639
    let csrf = csrf_of(&get(app, "/settings", Some(&andy)).await.body);
640
    let r = post(app, "/settings", Some(&andy), &format!("_csrf={csrf}&display_name=Andy+McCall&email=a%40example.com&bio=Builds+%3Cb%3Eoxy%3C%2Fb%3E&website=javascript%3Aalert(1)&location=Earth")).await;
641
    assert!(r.body.contains("must be an http"), "{}", r.body);
642
    let r = post(app, "/settings", Some(&andy), &format!("_csrf={csrf}&display_name=Andy+McCall&email=a%40example.com&bio=Builds+%3Cb%3Eoxy%3C%2Fb%3E&website=https%3A%2F%2Fexample.org&location=Earth")).await;
643
    assert!(r.body.contains("Profile saved"), "{}", r.body);
644
645
    let org = db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap();
646
    db::accounts::set_org_member(&app.db, org.id, bob_id, "member").await.unwrap();
647
    let team = db::accounts::create_team(&app.db, org.id, "core").await.unwrap();
648
    db::accounts::add_team_member(&app.db, team.id, bob_id).await.unwrap();
649
650
    let page = get(app, "/andy", Some(&bob)).await;
651
    assert_eq!(page.status, StatusCode::OK);
652
    for want in ["Andy McCall", "Earth", r#"href="https://example.org""#, "/avatar/andy", "Signing keys", "/retro", "demo"] {
653
        assert!(page.body.contains(want), "profile lacks {want}: {}", page.body);
654
    }
655
    assert!(!page.body.contains("<b>oxy</b>"), "the bio is escaped");
656
    assert!(!page.body.contains("[email protected]"), "the email stays private");
657
    assert!(!page.body.contains("/andy/hidden"), "bob cannot see the private repository");
658
    assert!(!page.body.contains("edit profile"), "only the owner gets the edit link");
659
    assert!(get(app, "/andy", Some(&andy)).await.body.contains("/andy/hidden"), "andy sees his own private repository");
660
661
    let page = get(app, "/retro", Some(&bob)).await;
662
    assert_eq!(page.status, StatusCode::OK);
663
    for want in ["Retro Club", "organisation", r#"Members <span class="count-chip">2</span>"#, "core", "1 member"] {
664
        assert!(page.body.contains(want), "org page lacks {want}: {}", page.body);
665
    }
666
    assert!(!page.body.contains("/orgs/retro/settings"), "bob is not an owner");
667
    assert!(get(app, "/retro", Some(&andy)).await.body.contains("/orgs/retro/settings"));
668
669
    let svg = get(app, "/avatar/andy", None).await;
670
    assert_eq!(svg.status, StatusCode::OK);
671
    assert!(svg.body.starts_with("<svg"));
672
    assert_eq!(svg.body, get(app, "/avatar/ANDY", None).await.body);
673
    assert_eq!(get(app, "/nobody-here", None).await.status, StatusCode::NOT_FOUND);
674
}
675
676
#[tokio::test(flavor = "multi_thread")]
677
async fn diffs_mark_words_hide_whitespace_and_collapse() {
678
    let h = hub().await;
679
    let app = &h.app;
680
    let andy = register(app, "andy").await;
681
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
682
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
683
    let work = tempfile::tempdir().unwrap();
684
    let mut client = Repo::init(work.path()).unwrap();
685
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
686
    client.config.set("remote.hub.token", "site").unwrap();
687
    client.save_config().unwrap();
688
    write(work.path(), "main.rs", b"fn main() {\n  setup();\n  let answer = 42;\n}\n");
689
    client.commit("first", &[]).unwrap();
690
    write(work.path(), "main.rs", b"fn main() {\n    setup();\n  let answer = 43;\n}\n");
691
    let second = client.commit("reindent and change the answer", &[]).unwrap();
692
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
693
694
    let url = format!("/andy/demo/change/{}", second.to_hex());
695
    let page = get(app, &url, Some(&andy)).await;
696
    assert_eq!(page.status, StatusCode::OK);
697
    assert!(page.body.contains(r#"<details class="file-diff""#), "files collapse: {}", page.body);
698
    assert!(page.body.contains(r#"<span class="wx">43</span>"#), "the changed word is marked: {}", page.body);
699
    assert!(page.body.contains("hide whitespace changes"));
700
    assert!(page.body.contains("data-diff-mode=\"split\""));
701
    let adds = |b: &str| b.matches(r#"<tr class="add">"#).count();
702
    assert_eq!(adds(&page.body), 2, "re-indent and answer both changed");
703
704
    let quiet = get(app, &format!("{url}?w=1"), Some(&andy)).await;
705
    assert_eq!(adds(&quiet.body), 1, "only the answer changed ignoring whitespace: {}", quiet.body);
706
    assert!(quiet.body.contains("show whitespace changes"));
707
708
    assert!(quiet.body.contains("?w=0"), "the toggle leads back");
709
}
710
711
#[tokio::test(flavor = "multi_thread")]
712
async fn fork_and_propose_upstream() {
713
    let h = hub().await;
714
    let app = &h.app;
715
    let andy = register(app, "andy").await;
716
    let bob = register(app, "bob").await;
717
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
718
    db::repos::create(&app.db, "andy", "demo", "The demo", hub_api::Visibility::Public).await.unwrap();
719
    let work = tempfile::tempdir().unwrap();
720
    let mut client = Repo::init(work.path()).unwrap();
721
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
722
    client.config.set("remote.hub.token", "site").unwrap();
723
    client.save_config().unwrap();
724
    write(work.path(), "README.md", b"# demo\n");
725
    client.commit("first", &[]).unwrap();
726
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
727
728
    // Bob forks it; he cannot fork into Andy's name.
729
    let page = get(app, "/andy/demo", Some(&bob)).await;
730
    assert!(page.body.contains("/andy/demo/fork"), "a fork button: {}", page.body);
731
    let csrf = csrf_of(&get(app, "/andy/demo/fork", Some(&bob)).await.body);
732
    let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=andy&name=other")).await;
733
    assert!(r.body.contains("cannot create repositories under that owner"), "{}", r.body);
734
    let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=bob&name=demo")).await;
735
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
736
    assert_eq!(r.location.as_deref(), Some("/bob/demo"));
737
    let forked = get(app, "/bob/demo", Some(&bob)).await;
738
    assert!(forked.body.contains("README.md") && forked.body.contains("forked from"), "{}", forked.body);
739
    assert!(forked.body.contains("propose to andy/demo"));
740
    assert!(get(app, "/andy/demo", None).await.body.contains("1 fork"));
741
    assert!(get(app, "/andy/demo/forks", None).await.body.contains("/bob/demo"));
742
    let fork_row = db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap();
743
    let up_row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
744
    assert_eq!(fork_row.forked_from, Some(up_row.id));
745
    assert_eq!(fork_row.description, "The demo");
746
    let fork_repo = app.repos.open(&RepoName::parse("bob/demo").unwrap()).unwrap();
747
    let up_repo = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap();
748
    assert_ne!(fork_repo.id().unwrap(), up_repo.id().unwrap(), "a fork is a repository of its own");
749
    assert_eq!(fork_repo.bookmark_heads().unwrap(), up_repo.bookmark_heads().unwrap());
750
    assert_eq!(fork_repo.fsck().unwrap().errors(), 0);
751
752
    // Bob works in a clone of his fork, pushes a bookmark, and proposes it upstream.
753
    let bw = tempfile::tempdir().unwrap();
754
    let mut mine = Repo::clone(&format!("{}/bob/demo", h.base), &bw.path().join("c")).unwrap();
755
    mine.config.set("remote.origin.token", "site").unwrap();
756
    mine.save_config().unwrap();
757
    mine.bookmark_create("feat", None).unwrap();
758
    mine.bookmark_switch("feat", false).unwrap();
759
    write(&bw.path().join("c"), "feature.txt", b"new\n");
760
    mine.commit("Add a feature", &[]).unwrap();
761
    assert!(mine.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
762
    let csrf = csrf_of(&get(app, "/andy/demo/proposals/new?source=bob/demo:feat", Some(&bob)).await.body);
763
    let r =
764
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+my+fork&target=main&source=bob%2Fdemo%3Afeat"))
765
            .await;
766
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
767
    let p = get(app, &r.location.unwrap(), Some(&andy)).await;
768
    assert!(p.body.contains("From my fork") && p.body.contains("fork bob/demo"), "{}", p.body);
769
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&andy)).await;
770
    assert!(files.body.contains("feature.txt"), "{}", files.body);
771
}
772
773
#[tokio::test(flavor = "multi_thread")]
774
async fn stars_topics_and_explore() {
775
    let h = hub().await;
776
    let app = &h.app;
777
    let andy = register(app, "andy").await;
778
    let bob = register(app, "bob").await;
779
    for (name, desc) in [("alpha", "An emulator"), ("beta", "A compiler"), ("gamma", "Retro games")] {
780
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
781
        db::repos::create(&app.db, "andy", name, desc, hub_api::Visibility::Public).await.unwrap();
782
    }
783
784
    // Topics through settings; bad ones are refused.
785
    let csrf = csrf_of(&get(app, "/andy/gamma/settings", Some(&andy)).await.body);
786
    let r = post(
787
        app,
788
        "/andy/gamma/settings",
789
        Some(&andy),
790
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=c%2B%2B"),
791
    )
792
    .await;
793
    let r = settled(app, r, Some(&andy), "/andy/gamma/settings/general").await;
794
    assert!(r.body.contains("is not a topic"), "{}", r.body);
795
    let r = post(
796
        app,
797
        "/andy/gamma/settings",
798
        Some(&andy),
799
        &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=Retro%2C+6502"),
800
    )
801
    .await;
802
    let r = settled(app, r, Some(&andy), "/andy/gamma/settings/general").await;
803
    assert!(r.body.contains("Saved"), "{}", r.body);
804
    assert_eq!(db::repos::get(&app.db, "andy", "gamma").await.unwrap().unwrap().topics, "retro 6502");
805
    let page = get(app, "/andy/gamma", None).await;
806
    assert!(page.body.contains(r#"href="/explore?topic=retro""#), "{}", page.body);
807
808
    // Stars: bob stars beta and gamma, andy stars gamma; toggling unstars.
809
    for (who, repo) in [(&bob, "beta"), (&bob, "gamma"), (&andy, "gamma"), (&andy, "alpha")] {
810
        let csrf = csrf_of(&get(app, &format!("/andy/{repo}"), Some(who)).await.body);
811
        assert_eq!(post(app, &format!("/andy/{repo}/star"), Some(who), &format!("_csrf={csrf}")).await.status, StatusCode::SEE_OTHER);
812
    }
813
    let csrf = csrf_of(&get(app, "/andy/alpha", Some(&andy)).await.body);
814
    post(app, "/andy/alpha/star", Some(&andy), &format!("_csrf={csrf}")).await;
815
    assert!(get(app, "/andy/gamma", Some(&bob)).await.body.contains("★ UNSTAR"));
816
    assert!(get(app, "/andy/gamma", None).await.body.contains(r#"title="2 stars">"#));
817
    assert!(get(app, "/andy/alpha", None).await.body.contains(r#"title="0 stars">"#), "unstarred again");
818
819
    // Explore: sort by stars, by name, filter by text and by topic.
820
    let order = |b: &str| {
821
        ["alpha", "beta", "gamma"]
822
            .into_iter()
823
            .map(|n| (b.find(&format!("/andy/{n}\"")).unwrap_or(usize::MAX), n))
824
            .collect::<std::collections::BTreeMap<_, _>>()
825
            .into_values()
826
            .collect::<Vec<_>>()
827
    };
828
    assert_eq!(order(&get(app, "/explore?sort=stars", None).await.body)[..2], ["gamma", "beta"]);
829
    assert_eq!(order(&get(app, "/explore?sort=name", None).await.body), ["alpha", "beta", "gamma"]);
830
    let filtered = get(app, "/explore?q=compiler", None).await.body;
831
    assert!(filtered.contains("/andy/beta\"") && !filtered.contains("/andy/alpha\""), "{filtered}");
832
    let by_topic = get(app, "/explore?topic=6502", None).await.body;
833
    assert!(by_topic.contains("/andy/gamma\"") && !by_topic.contains("/andy/beta\""), "{by_topic}");
834
}
835
836
#[tokio::test(flavor = "multi_thread")]
837
async fn lists_filter_sort_page_and_count() {
838
    let h = hub().await;
839
    let app = &h.app;
840
    let andy = register(app, "andy").await;
841
    let _bob = register(app, "bob").await;
842
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
843
    let repo = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
844
    for n in 0..30 {
845
        let author = if n % 3 == 0 { "bob" } else { "andy" };
846
        db::issues::create(
847
            &app.db,
848
            db::issues::NewIssue { repo_id: repo.id, title: &format!("Issue number {n}"), body: "", author_id: None, author },
849
        )
850
        .await
851
        .unwrap();
852
    }
853
    let ui = db::issues::upsert_label(&app.db, repo.id, "ui", "#00f0ff", "").await.unwrap();
854
    for (title, author, target) in [("Faster menus", "bob", "main"), ("New theme", "andy", "main"), ("Release prep", "andy", "stable")] {
855
        let p = db::proposals::create(
856
            &app.db,
857
            db::proposals::NewProposal {
858
                repo_id: repo.id,
859
                title,
860
                body: "",
861
                author_id: None,
862
                author,
863
                draft: false,
864
                target,
865
                source_kind: "changes",
866
                source_repo_id: None,
867
                source_bookmark: "",
868
                source_changes: "",
869
            },
870
        )
871
        .await
872
        .unwrap();
873
        if title == "New theme" {
874
            db::issues::set_proposal_label(&app.db, p.id, ui.id, true).await.unwrap();
875
        }
876
    }
877
878
    // The tabs count what is open.
879
    let page = get(app, "/andy/demo/issues", Some(&andy)).await;
880
    assert!(page.body.contains(r#"Issues <span class="count-chip">30</span>"#), "{}", page.body);
881
    assert!(page.body.contains(r#"Proposals <span class="count-chip">3</span>"#));
882
883
    // Pages of 25, newest first; the second page holds the rest.
884
    let rows = |b: &str| b.matches("/andy/demo/issue/").count();
885
    assert_eq!(rows(&page.body), 25);
886
    assert!(page.body.contains("page 1 of 2") && page.body.contains("page=2"), "a pager");
887
    let second = get(app, "/andy/demo/issues?page=2", Some(&andy)).await;
888
    assert_eq!(rows(&second.body), 5);
889
    assert!(second.body.contains("Issue number 0<"), "the oldest are last");
890
    let oldest = get(app, "/andy/demo/issues?sort=oldest", Some(&andy)).await;
891
    assert!(oldest.body.find("Issue number 0<").unwrap() < oldest.body.find("Issue number 1<").unwrap());
892
    let bobs = get(app, "/andy/demo/issues?q=author:bob", Some(&andy)).await;
893
    assert_eq!(rows(&bobs.body), 10, "{}", bobs.body);
894
895
    // Proposals: by author, label and target.
896
    let by_bob = get(app, "/andy/demo/proposals?q=author:bob", Some(&andy)).await.body;
897
    assert!(by_bob.contains("Faster menus") && !by_bob.contains("New theme"));
898
    let ui_only = get(app, "/andy/demo/proposals?q=label:ui", Some(&andy)).await.body;
899
    assert!(ui_only.contains("New theme") && !ui_only.contains("Faster menus") && ui_only.contains(r#"class="label""#));
900
    let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body;
901
    assert!(stable.contains("Release prep") && !stable.contains("New theme"));
902
}
903
904
#[tokio::test(flavor = "multi_thread")]
905
async fn project_boards() {
906
    let h = hub().await;
907
    let app = &h.app;
908
    let andy = register(app, "andy").await;
909
    let bob = register(app, "bob").await;
910
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
911
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
912
        let row = db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
913
        db::issues::create(
914
            &app.db,
915
            db::issues::NewIssue { repo_id: row.id, title: &format!("{name} issue"), body: "", author_id: None, author: "andy" },
916
        )
917
        .await
918
        .unwrap();
919
    }
920
    // Found from anywhere (#119): the site bar and the owner's profile (no
921
    // longer the repository's tabs, #254), and a New project button on the
922
    // list; a stranger is told who can make one.
923
    let home = get(app, "/andy/demo", Some(&andy)).await.body;
924
    assert!(home.contains("href=\"/projects/andy\"><svg class=\"ic\""), "site bar");
925
    assert!(!home.contains("project boards\">Projects</a>"), "not a repository tab");
926
    assert!(get(app, "/andy", Some(&bob)).await.body.contains("href=\"/projects/andy\">Projects</a>"), "the owner's profile");
927
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
928
    assert!(list.contains("href=\"#new\">+ NEW PROJECT</a>") && list.contains("id=\"new\""), "the button and the form");
929
    assert!(get(app, "/projects/andy", Some(&bob)).await.body.contains("Only andy"), "who can make one");
930
    let csrf = csrf_of(&list);
931
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await;
932
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body);
933
    // The list (#124): a row per project, counted under Open, and a filter.
934
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
935
    assert!(
936
        list.contains(">Roadmap</a>") && list.contains("Next steps") && list.contains("Open <span class=\"count-chip\">1</span>"),
937
        "{list}"
938
    );
939
    let filtered = get(app, "/projects/andy?q=roadm", Some(&andy)).await.body;
940
    assert!(filtered.contains(">Roadmap</a>"), "a filter matches part of the title, any case");
941
    let none = get(app, "/projects/andy?q=nothing+like+it", Some(&andy)).await.body;
942
    assert!(none.contains("No open project matches") && !none.contains(">Roadmap</a>"), "{none}");
943
    let closed = get(app, "/projects/andy?state=closed", Some(&andy)).await.body;
944
    assert!(closed.contains("No closed projects.") && closed.contains("Closed <span class=\"count-chip\">0</span>"), "{closed}");
945
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
946
    for col in ["Backlog", "Doing", "Done"] {
947
        assert!(board.contains(&format!(">{col} <span")), "{col}");
948
    }
949
    let cols: Vec<i64> = sqlx::query_scalar("SELECT id FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap();
950
    let act = |form: String| {
951
        let andy = andy.clone();
952
        async move { post(app, "/projects/andy/1", Some(&andy), &form).await }
953
    };
954
    // Cards: by reference, by repository and number, a note; duplicates and strangers refused.
955
    assert_eq!(
956
        act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[0])).await.location.as_deref(),
957
        Some("/projects/andy/1")
958
    );
959
    act(format!("_csrf={csrf}&action=add&column={}&repo=secret&reference=%231", cols[0])).await;
960
    act(format!("_csrf={csrf}&action=add&column={}&note=Write+the+docs", cols[1])).await;
961
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[1])).await.body.contains("already on this project"));
962
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%2399", cols[0])).await.body.contains("no issue or proposal #99"));
963
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=nothere%231", cols[0])).await.body.contains("no repository nothere"));
964
    // Move demo#1 to Done.
965
    let demo_card: i64 =
966
        sqlx::query_scalar("SELECT c.id FROM project_cards c JOIN repositories r ON r.id = c.repo_id WHERE r.name = 'demo'")
967
            .fetch_one(&app.db)
968
            .await
969
            .unwrap();
970
    act(format!("_csrf={csrf}&action=move&card={demo_card}&column={}", cols[2])).await;
971
    let issue = get(app, "/andy/demo/issue/1", None).await.body;
972
    assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is");
973
974
    // Readers see public cards, never those of repositories they cannot read.
975
    let anon = get(app, "/projects/andy/1", None).await.body;
976
    assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}");
977
    assert!(!anon.contains("name=\"action\""), "and cannot change anything");
978
    assert!(get(app, "/projects/andy/1", Some(&andy)).await.body.contains("secret issue"));
979
    // Only the owner changes it; a private project is invisible to others.
980
    let csrf_b = csrf_of(&get(app, "/settings", Some(&bob)).await.body);
981
    assert_eq!(
982
        post(app, "/projects/andy/1", Some(&bob), &format!("_csrf={csrf_b}&action=remove&card={demo_card}")).await.status,
983
        StatusCode::NOT_FOUND
984
    );
985
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&description=&private=1")).await;
986
    assert_eq!(get(app, "/projects/andy/1", Some(&bob)).await.status, StatusCode::NOT_FOUND);
987
    assert!(!get(app, "/projects/andy", None).await.body.contains("Roadmap"), "not even listed");
988
    assert!(!get(app, "/andy/demo/issue/1", None).await.body.contains("Roadmap"), "nor on the issue");
989
990
    // Columns: removing one moves its cards to the first column; the last one stays.
991
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[1])).await;
992
    let note_col: i64 = sqlx::query_scalar("SELECT column_id FROM project_cards WHERE kind = 'note'").fetch_one(&app.db).await.unwrap();
993
    assert_eq!(note_col, cols[0]);
994
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[2])).await;
995
    assert!(act(format!("_csrf={csrf}&action=remove_column&column={}", cols[0])).await.body.contains("at least one column"));
996
    // Closed projects are read-only until reopened; then deleted, the issues remain.
997
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&closed=1")).await;
998
    assert!(act(format!("_csrf={csrf}&action=add&column={}&note=late", cols[0])).await.body.contains("This project is closed"));
999
    let r = act(format!("_csrf={csrf}&action=delete")).await;
1000
    assert_eq!(r.location.as_deref(), Some("/projects/andy"));
1001
    assert_eq!(get(app, "/projects/andy/1", Some(&andy)).await.status, StatusCode::NOT_FOUND);
1002
    assert_eq!(get(app, "/andy/demo/issue/1", None).await.status, StatusCode::OK);
1003
    assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND);
1004
}
1005
1006
/// #293: a long column shows its newest cards, says how many older ones it
1007
/// leaves out and links to them all; a filter finds cards in any column.
1008
#[tokio::test(flavor = "multi_thread")]
1009
async fn a_long_column_shows_its_newest_cards() {
1010
    let h = hub().await;
1011
    let app = &h.app;
1012
    let andy = register(app, "andy").await;
1013
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
1014
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1015
    let (project, done): (i64, i64) =
1016
        sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id WHERE c.name = 'Done'")
1017
            .fetch_one(&app.db)
1018
            .await
1019
            .unwrap();
1020
    for i in 1..=35 {
1021
        sqlx::query(
1022
            "INSERT INTO project_cards (project_id, column_id, position, kind, note, created_ms) VALUES ($1, $2, $3, 'note', $4, 0)",
1023
        )
1024
        .bind(project)
1025
        .bind(done)
1026
        .bind(i)
1027
        .bind(format!("card-{i:03}"))
1028
        .execute(&app.db)
1029
        .await
1030
        .unwrap();
1031
    }
1032
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
1033
    assert!(board.contains("5 older cards not shown") && board.contains(&format!("?all={done}#col-{done}\">show all 35</a>")), "{board}");
1034
    assert!(board.contains("card-006") && board.contains("card-035") && !board.contains("card-005"), "the newest 30");
1035
    assert!(board.contains(">35</span></h2>"), "the count is the column's");
1036
    let all = get(app, &format!("/projects/andy/1?all={done}"), Some(&andy)).await.body;
1037
    assert!(all.contains("card-001") && !all.contains("older cards not shown"), "show all");
1038
    let found = get(app, "/projects/andy/1?q=CARD-002", Some(&andy)).await.body;
1039
    assert!(found.contains("card-002") && !found.contains("card-030") && found.contains("1 of 35"), "{found}");
1040
    assert!(found.contains(">clear</a>"));
1041
    let open = get(app, "/projects/andy/1?state=open", Some(&andy)).await.body;
1042
    assert!(!open.contains("card-0"), "notes have no state");
1043
}
1044
1045
/// #293: a card shows its issue's labels and assignees, each a link to
1046
/// the board filtered by it, and the filter takes `label:`, `assignee:`
1047
/// and `state:` with words, every term holding.
1048
#[tokio::test(flavor = "multi_thread")]
1049
async fn a_board_filters_by_label_and_assignee() {
1050
    let h = hub().await;
1051
    let app = &h.app;
1052
    let andy = register(app, "andy").await;
1053
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
1054
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1055
    app.repos.create(&RepoName::parse("andy/game").unwrap(), None).unwrap();
1056
    let row = db::repos::create(&app.db, "andy", "game", "", hub_api::Visibility::Public).await.unwrap();
1057
    let mut ids = Vec::new();
1058
    for title in ["Sound crackles", "Jump is floaty", "Title screen"] {
1059
        let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: row.id, title, body: "", author_id: None, author: "andy" })
1060
            .await
1061
            .unwrap();
1062
        ids.push(i.id);
1063
    }
1064
    let bug = db::issues::upsert_label(&app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap();
1065
    let feel = db::issues::upsert_label(&app.db, row.id, "feel", "#0e8a16", "").await.unwrap();
1066
    db::issues::set_issue_label(&app.db, ids[0], bug.id, true).await.unwrap();
1067
    db::issues::set_issue_label(&app.db, ids[1], bug.id, true).await.unwrap();
1068
    db::issues::set_issue_label(&app.db, ids[1], feel.id, true).await.unwrap();
1069
    let (andy_id,): (i64,) = sqlx::query_as("SELECT id FROM accounts WHERE username = 'andy'").fetch_one(&app.db).await.unwrap();
1070
    db::issues::set_assignee(&app.db, ids[1], andy_id, true).await.unwrap();
1071
    let (project, todo): (i64, i64) =
1072
        sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id ORDER BY c.position LIMIT 1")
1073
            .fetch_one(&app.db)
1074
            .await
1075
            .unwrap();
1076
    for n in 1..=3i64 {
1077
        sqlx::query(
1078
            "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, created_ms) VALUES ($1, $2, $3, 'issue', $4, $5, '', 0)",
1079
        )
1080
        .bind(project)
1081
        .bind(todo)
1082
        .bind(n)
1083
        .bind(row.id)
1084
        .bind(n)
1085
        .execute(&app.db)
1086
        .await
1087
        .unwrap();
1088
    }
1089
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
1090
    assert!(board.contains("href=\"/projects/andy/1?q=label:bug\""), "{board}");
1091
    assert!(board.contains("title=\"Something is wrong\">bug</a>"), "a label's description is its title");
1092
    assert!(board.contains(">@andy</a>"), "{board}");
1093
    let bugs = get(app, "/projects/andy/1?q=label:bug", Some(&andy)).await.body;
1094
    assert!(bugs.contains("Sound crackles") && bugs.contains("Jump is floaty") && !bugs.contains("Title screen"), "{bugs}");
1095
    assert!(bugs.contains("2 of 3"));
1096
    let both = get(app, "/projects/andy/1?q=label:BUG+label:feel", Some(&andy)).await.body;
1097
    assert!(!both.contains("Sound crackles") && both.contains("Jump is floaty"), "every label must hold");
1098
    let mine = get(app, "/projects/andy/1?q=assignee:%40andy", Some(&andy)).await.body;
1099
    assert!(mine.contains("Jump is floaty") && !mine.contains("Sound crackles"), "{mine}");
1100
    let words = get(app, "/projects/andy/1?q=label:bug+sound", Some(&andy)).await.body;
1101
    assert!(words.contains("Sound crackles") && !words.contains("Jump is floaty"));
1102
    let closed = get(app, "/projects/andy/1?q=state:closed", Some(&andy)).await.body;
1103
    assert!(closed.contains("0 of 3"), "{closed}");
1104
}
1105
1106
/// #293: a card is archived off the board, kept and found on the archive
1107
/// view, and restored; a column archives its cards older than some days
1108
/// at once, or on its own once set to; an issue's page says its card is
1109
/// archived.
1110
#[tokio::test(flavor = "multi_thread")]
1111
async fn board_cards_are_archived_and_restored() {
1112
    let h = hub().await;
1113
    let app = &h.app;
1114
    let andy = register(app, "andy").await;
1115
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
1116
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1117
    let (project, done): (i64, i64) =
1118
        sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id WHERE c.name = 'Done'")
1119
            .fetch_one(&app.db)
1120
            .await
1121
            .unwrap();
1122
    let day = 86_400_000i64;
1123
    let now = db::now_ms();
1124
    for (i, age) in [40, 20, 10, 1].iter().enumerate() {
1125
        sqlx::query(
1126
            "INSERT INTO project_cards (project_id, column_id, position, kind, note, created_ms, moved_ms) VALUES ($1, $2, $3, 'note', $4, 0, $5)",
1127
        )
1128
        .bind(project)
1129
        .bind(done)
1130
        .bind(i as i64)
1131
        .bind(format!("aged-{age}"))
1132
        .bind(now - age * day)
1133
        .execute(&app.db)
1134
        .await
1135
        .unwrap();
1136
    }
1137
    let id_of = |note: &'static str| async move {
1138
        sqlx::query_scalar::<_, i64>("SELECT id FROM project_cards WHERE note = $1").bind(note).fetch_one(&app.db).await.unwrap()
1139
    };
1140
    let board = "/projects/andy/1";
1141
    let (who, token) = (&andy, &csrf);
1142
    let act = |body: String| async move { post(app, board, Some(who), &format!("_csrf={token}&{body}")).await };
1143
1144
    // One card by hand: off the board, on the archive view, restorable.
1145
    let forty = id_of("aged-40").await;
1146
    act(format!("action=archive&card={forty}")).await;
1147
    let page = get(app, board, Some(&andy)).await.body;
1148
    assert!(!page.contains("aged-40") && page.contains("aged-20"), "{page}");
1149
    assert!(page.contains("?archived=1\">1 archived card</a>"), "{page}");
1150
    let archived = get(app, &format!("{board}?archived=1"), Some(&andy)).await.body;
1151
    assert!(archived.contains("aged-40") && !archived.contains("aged-20"), "{archived}");
1152
    assert!(archived.contains("value=\"restore\"") && archived.contains("Back to the board"));
1153
    assert!(!archived.contains("add a card"), "the archive view adds nothing");
1154
    let found = get(app, &format!("{board}?archived=1&q=aged-40"), Some(&andy)).await.body;
1155
    assert!(found.contains("1 of 1") && found.contains("name=\"archived\" value=\"1\""), "{found}");
1156
    act(format!("action=restore&card={forty}")).await;
1157
    assert!(get(app, board, Some(&andy)).await.body.contains("aged-40"));
1158
1159
    // The column's cards there more than 15 days, at once: 40 (just
1160
    // restored, so it joined again now) stays, 20 goes.
1161
    let r = act(format!("action=archive_older&column={done}&days=15")).await;
1162
    assert!(r.body.contains("Archived 1 card from Done."), "{}", r.body);
1163
    let page = get(app, board, Some(&andy)).await.body;
1164
    assert!(page.contains("aged-40") && !page.contains("aged-20") && page.contains("aged-10"));
1165
    let r = act(format!("action=archive_older&column={done}&days=soon")).await;
1166
    assert!(r.body.contains("Give a number of days"), "{}", r.body);
1167
1168
    // On its own: Done after 5 days; the hourly pass takes aged-10 only.
1169
    let r = act(format!("action=archive_after&column={done}&days=5")).await;
1170
    assert!(r.body.contains("Done archives its cards 5 days after they reach it."), "{}", r.body);
1171
    assert!(get(app, board, Some(&andy)).await.body.contains("archives cards 5 days after they reach it"));
1172
    assert_eq!(db::projects::archive_due(&app.db, db::now_ms()).await.unwrap(), 1);
1173
    let page = get(app, board, Some(&andy)).await.body;
1174
    assert!(page.contains("aged-40") && !page.contains("aged-10") && page.contains("aged-1<"), "{page}");
1175
    assert!(page.contains("2 archived cards"));
1176
    let r = act(format!("action=archive_after&column={done}&mode=never")).await;
1177
    assert!(r.body.contains("Done keeps its cards until you archive them."));
1178
    assert_eq!(db::projects::archive_due(&app.db, db::now_ms() + 100 * day).await.unwrap(), 0, "never, now");
1179
1180
    // The default (#293, Andy 2026-10-25): finished work, a closed issue,
1181
    // goes a fortnight after it reached its column; open work and notes stay.
1182
    let r = act(format!("action=archive_after&column={done}&mode=finished")).await;
1183
    assert!(r.body.contains("Done archives finished cards 14 days after they reach it."), "{}", r.body);
1184
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1185
    let repo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
1186
    let mut cards = vec![];
1187
    for title in ["shipped", "still open"] {
1188
        let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: repo.id, title, body: "", author_id: None, author: "andy" })
1189
            .await
1190
            .unwrap();
1191
        if title == "shipped" {
1192
            db::issues::set_state(&app.db, i.id, "closed", "completed").await.unwrap();
1193
        }
1194
        let id: i64 = sqlx::query_scalar(
1195
            "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, created_ms, moved_ms) \
1196
             VALUES ($1, $2, 10, 'issue', $3, $4, '', 0, $5) RETURNING id",
1197
        )
1198
        .bind(project)
1199
        .bind(done)
1200
        .bind(repo.id)
1201
        .bind(i.number)
1202
        .bind(now - 20 * day)
1203
        .fetch_one(&app.db)
1204
        .await
1205
        .unwrap();
1206
        cards.push(id);
1207
    }
1208
    assert_eq!(db::projects::archive_due(&app.db, db::now_ms()).await.unwrap(), 1, "the closed issue only");
1209
    let archived_ms = |id: i64| async move {
1210
        sqlx::query_scalar::<_, Option<i64>>("SELECT archived_ms FROM project_cards WHERE id = $1")
1211
            .bind(id)
1212
            .fetch_one(&app.db)
1213
            .await
1214
            .unwrap()
1215
    };
1216
    assert!(archived_ms(cards[0]).await.is_some() && archived_ms(cards[1]).await.is_none());
1217
    let page = get(app, board, Some(&andy)).await.body;
1218
    assert!(page.contains("value=\"finished\" selected"), "the column says so");
1219
}
1220
1221
/// #343: a board's iteration shows its days and burn-up; one is open at a
1222
/// time; closing it archives the Done column's cards under it, and its page
1223
/// shows them; a card restored from it no longer counts as done in it.
1224
#[tokio::test(flavor = "multi_thread")]
1225
async fn board_iterations_close_into_the_archive() {
1226
    let h = hub().await;
1227
    let app = &h.app;
1228
    let andy = register(app, "andy").await;
1229
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
1230
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1231
    let column = |name: &'static str| async move {
1232
        sqlx::query_scalar::<_, i64>("SELECT id FROM project_columns WHERE name = $1").bind(name).fetch_one(&app.db).await.unwrap()
1233
    };
1234
    let (backlog, done) = (column("Backlog").await, column("Done").await);
1235
    let project: i64 = sqlx::query_scalar("SELECT id FROM projects").fetch_one(&app.db).await.unwrap();
1236
    let day = 86_400_000i64;
1237
    let now = db::now_ms();
1238
    for (i, (note, col, age)) in [("done-a", done, 2), ("done-b", done, 0), ("todo-c", backlog, 5)].into_iter().enumerate() {
1239
        sqlx::query(
1240
            "INSERT INTO project_cards (project_id, column_id, position, kind, note, created_ms, moved_ms) VALUES ($1, $2, $3, 'note', $4, 0, $5)",
1241
        )
1242
        .bind(project)
1243
        .bind(col)
1244
        .bind(i as i64)
1245
        .bind(note)
1246
        .bind(now - age * day)
1247
        .execute(&app.db)
1248
        .await
1249
        .unwrap();
1250
    }
1251
    let date = |ms: i64| {
1252
        let t = time::OffsetDateTime::from_unix_timestamp(ms / 1000).unwrap();
1253
        format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day())
1254
    };
1255
    let board = "/projects/andy/1";
1256
    let (who, token) = (&andy, &csrf);
1257
    let act = |body: String| async move { post(app, board, Some(who), &format!("_csrf={token}&{body}")).await };
1258
1259
    // No iteration yet: the board offers to start one.
1260
    assert!(get(app, board, Some(&andy)).await.body.contains("start an iteration"));
1261
    let r = act(format!("action=start_iteration&name=Sprint+1&start={}&end=nonsense", date(now))).await;
1262
    assert!(r.body.contains("Give the first and last days as dates."), "{}", r.body);
1263
    let r = act(format!("action=start_iteration&name=Sprint+1&start={}&end={}", date(now), date(now - 2 * day))).await;
1264
    assert!(r.body.contains("ends on or after its first day"), "{}", r.body);
1265
    let r = act(format!("action=start_iteration&name=Sprint+1&start={}&end={}", date(now - 3 * day), date(now + 10 * day))).await;
1266
    assert!(r.body.contains("Sprint 1 started."), "{}", r.body);
1267
    let page = get(app, board, Some(&andy)).await.body;
1268
    assert!(page.contains("day 4 of 14 · 2 cards done"), "{page}");
1269
    // Four days so far of fourteen (20 units each): done-a reached Done on
1270
    // the second, done-b today.
1271
    assert!(page.contains("<polyline points=\"0,64 20,64 40,32 60,32 80,0\""), "{page}");
1272
    assert!(page.contains("value=\"close_iteration\""));
1273
    let r = act(format!("action=start_iteration&name=Sprint+2&start={}&end={}", date(now), date(now + day))).await;
1274
    assert!(r.body.contains("Close the open iteration before starting another."), "{}", r.body);
1275
1276
    // Closing it archives what is done; the rest stays on the board.
1277
    let it: i64 = sqlx::query_scalar("SELECT id FROM project_iterations").fetch_one(&app.db).await.unwrap();
1278
    let r = act(format!("action=close_iteration&iteration={it}")).await;
1279
    assert!(r.body.contains("Closed: 2 done cards archived under it."), "{}", r.body);
1280
    let page = get(app, board, Some(&andy)).await.body;
1281
    assert!(!page.contains("done-a") && !page.contains("done-b") && page.contains("todo-c"), "{page}");
1282
    assert!(page.contains(&format!("?iteration={it}\">Sprint 1</a>")) && page.contains("start an iteration"), "{page}");
1283
    let r = act(format!("action=close_iteration&iteration={it}")).await;
1284
    assert!(r.body.contains("That iteration is not open."), "{}", r.body);
1285
1286
    // Its page: the cards done in it, restorable, with its burn-up.
1287
    let past = get(app, &format!("{board}?iteration={it}"), Some(&andy)).await.body;
1288
    assert!(past.contains("done-a") && past.contains("done-b") && !past.contains("todo-c"), "{past}");
1289
    assert!(past.contains("2 cards done, archived when it closed") && past.contains("class=\"burnup\""), "{past}");
1290
    let a: i64 = sqlx::query_scalar("SELECT id FROM project_cards WHERE note = 'done-a'").fetch_one(&app.db).await.unwrap();
1291
    act(format!("action=restore&card={a}")).await;
1292
    let past = get(app, &format!("{board}?iteration={it}"), Some(&andy)).await.body;
1293
    assert!(!past.contains("done-a") && past.contains("1 card done"), "{past}");
1294
    assert!(get(app, board, Some(&andy)).await.body.contains("done-a"), "back on the board");
1295
}
1296
1297
/// The burn-up counts cards done by the end of each day so far (#343).
1298
#[test]
1299
fn a_burn_up_counts_each_days_done_cards() {
1300
    use oxygenhub::db::projects::burn_up;
1301
    let day = 86_400_000i64;
1302
    let start = 10 * day;
1303
    // Done before the start, on day one, twice on day three; the iteration
1304
    // has five days and it is midway through the fourth.
1305
    let times = [start - day, start + 1000, start + 2 * day + 5, start + 2 * day + 9];
1306
    assert_eq!(burn_up(&times, start, start + 5 * day, start + 3 * day + day / 2), [2, 2, 4, 4]);
1307
    // After its end: every day, no more.
1308
    assert_eq!(burn_up(&times, start, start + 5 * day, start + 40 * day).len(), 5);
1309
    // Before its start: nothing yet.
1310
    assert!(burn_up(&times, start, start + 5 * day, start - day).is_empty());
1311
}
1312
1313
#[tokio::test(flavor = "multi_thread")]
1314
async fn issue_markdown_follows_the_default_bookmark() {
1315
    let h = hub().await;
1316
    let app = &h.app;
1317
    register(app, "andy").await;
1318
    app.repos.create(&RepoName::parse("andy/trunky").unwrap(), None).unwrap();
1319
    let row = db::repos::create(&app.db, "andy", "trunky", "", hub_api::Visibility::Public).await.unwrap();
1320
    sqlx::query("UPDATE repositories SET default_bookmark = 'trunk' WHERE id = $1").bind(row.id).execute(&app.db).await.unwrap();
1321
    db::issues::create(
1322
        &app.db,
1323
        db::issues::NewIssue {
1324
            repo_id: row.id,
1325
            title: "Docs",
1326
            body: "See [the guide](docs/guide.md) and ![shot](img/a.png).",
1327
            author_id: None,
1328
            author: "andy",
1329
        },
1330
    )
1331
    .await
1332
    .unwrap();
1333
    let page = get(app, "/andy/trunky/issue/1", None).await.body;
1334
    assert!(page.contains("href=\"/andy/trunky/file/trunk/docs/guide.md\""), "{page}");
1335
    assert!(page.contains("src=\"/andy/trunky/raw/trunk/img/a.png\""));
1336
    assert!(!page.contains("/file/main/"));
1337
}
1338
1339
/// #125: a board moves to an owner its user may make boards under, takes the
1340
/// next number there with its cards, and its old address redirects to it; a
1341
/// new board at the old address takes it over.
1342
#[tokio::test(flavor = "multi_thread")]
1343
async fn a_project_transfers_to_an_organisation() {
1344
    let h = hub().await;
1345
    let app = &h.app;
1346
    // The first account is the site's administrator, who may make boards
1347
    // anywhere: Andy is an ordinary user here.
1348
    let _first = register(app, "ada").await;
1349
    let andy = register(app, "andy").await;
1350
    let _bob = register(app, "bob").await;
1351
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1352
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
1353
    db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap();
1354
    db::accounts::create_org(&app.db, "other", "Someone else's", bob_id).await.unwrap();
1355
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
1356
    let csrf = csrf_of(&list);
1357
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1358
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"));
1359
    post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=add&note=Keep+me")).await;
1360
1361
    // Settings offer the organisations Andy belongs to, not other people's.
1362
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
1363
    assert!(board.contains("<option value=\"retro\">retro</option>") && !board.contains("value=\"other\""), "{board}");
1364
    let refused = post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=transfer&to=other")).await;
1365
    assert!(refused.body.contains("You cannot make projects under other"), "{}", refused.body);
1366
1367
    let r = post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=transfer&to=retro")).await;
1368
    assert_eq!(r.location.as_deref(), Some("/projects/retro/1"), "{}", r.body);
1369
    let moved = get(app, "/projects/retro/1", Some(&andy)).await.body;
1370
    assert!(moved.contains("Roadmap") && moved.contains("Keep me"), "the cards come too");
1371
    let old = get(app, "/projects/andy/1", Some(&andy)).await;
1372
    assert_eq!(old.status, StatusCode::PERMANENT_REDIRECT);
1373
    assert_eq!(old.location.as_deref(), Some("/projects/retro/1"));
1374
1375
    // A new board of Andy's takes number 1 again, and its own address back.
1376
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Fresh")).await;
1377
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"));
1378
    let fresh = get(app, "/projects/andy/1", Some(&andy)).await;
1379
    assert_eq!(fresh.status, StatusCode::OK);
1380
    assert!(fresh.body.contains("Fresh"));
1381
}
1382
1383
/// Backups' settings (#169): a user's page, its checks, and an
1384
/// organisation's page for its owners only.
1385
#[tokio::test(flavor = "multi_thread")]
1386
async fn backups_are_set_up_in_settings() {
1387
    let h = hub().await;
1388
    let app = &h.app;
1389
    let andy = register(app, "andy").await;
1390
    let page = get(app, "/settings/backups", Some(&andy)).await;
1391
    assert_eq!(page.status, StatusCode::OK);
1392
    assert!(page.body.contains("href=\"/settings/backups\" class=\"on\"") && page.body.contains("No runs yet."), "{}", page.body);
1393
    let csrf = csrf_of(&page.body);
1394
    // This hub allows no directory: only a bucket, and a real provider name.
1395
    let bad = post(
1396
        app,
1397
        "/settings/backups",
1398
        Some(&andy),
1399
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=andy-backup&provider=nope&keep_days=30"),
1400
    )
1401
    .await;
1402
    assert!(bad.body.contains("workload identity provider"), "{}", bad.body);
1403
    let dir =
1404
        post(app, "/settings/backups", Some(&andy), &format!("_csrf={csrf}&every=hourly&destination=dir&dir=%2Ftmp&keep_days=30")).await;
1405
    assert!(dir.body.contains("Pick one of the directories this hub allows."));
1406
    let provider =
1407
        "%2F%2Fiam.googleapis.com%2Fprojects%2F1%2Flocations%2Fglobal%2FworkloadIdentityPools%2Foxygenhub%2Fproviders%2Foxygenhub-io";
1408
    let ok = post(
1409
        app,
1410
        "/settings/backups",
1411
        Some(&andy),
1412
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=gs%3A%2F%2Fandy-backup&provider={provider}&keep_days=7"),
1413
    )
1414
    .await;
1415
    assert!(ok.body.contains("Saved."), "{}", ok.body);
1416
    let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap();
1417
    assert_eq!((s.target.as_str(), s.keep_days, s.every.as_str()), ("andy-backup", 7, "hourly"));
1418
    // An S3 bucket (BP4): its region and the role the hub assumes.
1419
    let bad = post(
1420
        app,
1421
        "/settings/backups",
1422
        Some(&andy),
1423
        &format!("_csrf={csrf}&every=hourly&destination=s3&s3_bucket=andy-backup&s3_region=eu-west-2&s3_role=admin&keep_days=30"),
1424
    )
1425
    .await;
1426
    assert!(bad.body.contains("the IAM role"), "{}", bad.body);
1427
    let role = "arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Foxygenhub-backup";
1428
    let ok = post(
1429
        app,
1430
        "/settings/backups",
1431
        Some(&andy),
1432
        &format!(
1433
            "_csrf={csrf}&every=daily&destination=s3&s3_bucket=s3%3A%2F%2Fandy-backup&s3_region=eu-west-2&s3_role={role}&keep_days=30"
1434
        ),
1435
    )
1436
    .await;
1437
    assert!(ok.body.contains("Saved.") && ok.body.contains(r#"value="s3" checked"#), "{}", ok.body);
1438
    let s = oxygenhub::backups::setting(&app.db, "andy").await.unwrap().unwrap();
1439
    assert_eq!((s.destination.as_str(), s.target.as_str(), s.region.as_str()), ("s3", "andy-backup", "eu-west-2"));
1440
    assert_eq!(s.provider, "arn:aws:iam::123456789012:role/oxygenhub-backup");
1441
    // Back to GCS for what follows.
1442
    let ok = post(
1443
        app,
1444
        "/settings/backups",
1445
        Some(&andy),
1446
        &format!("_csrf={csrf}&every=hourly&destination=gcs&bucket=gs%3A%2F%2Fandy-backup&provider={provider}&keep_days=7"),
1447
    )
1448
    .await;
1449
    assert!(ok.body.contains("Saved."));
1450
    // An organisation's backups are its owners' business only.
1451
    let bob = register(app, "bob").await;
1452
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1453
    db::accounts::create_org(&app.db, "club", "Club", andy_id).await.unwrap();
1454
    assert_eq!(get(app, "/orgs/club/settings/backups", Some(&bob)).await.status, StatusCode::NOT_FOUND);
1455
    let club = get(app, "/orgs/club/settings/backups", Some(&andy)).await;
1456
    assert!(club.body.contains("club</a>'s repositories"), "{}", club.body);
1457
    assert!(get(app, "/orgs/club/settings", Some(&andy)).await.body.contains("/orgs/club/settings/backups"));
1458
1459
    // Admin › Backups (docs/backups.md §5): whether each ran, and who has
1460
    // none; administrators only.
1461
    sqlx::query("UPDATE accounts SET is_admin = (username = 'andy')").execute(&app.db).await.unwrap();
1462
    app.repos.create(&oxygenhub::repos::RepoName::parse("bob/game").unwrap(), None).unwrap();
1463
    db::repos::create(&app.db, "bob", "game", "", hub_api::Visibility::Public).await.unwrap();
1464
    assert_eq!(get(app, "/admin/backups", Some(&bob)).await.status, StatusCode::NOT_FOUND);
1465
    let admin = get(app, "/admin/backups", Some(&andy)).await;
1466
    assert_eq!(admin.status, StatusCode::OK);
1467
    assert!(admin.body.contains("gs://andy-backup") && admin.body.contains("hourly") && admin.body.contains("never"), "{}", admin.body);
1468
    let missing = &admin.body[admin.body.find("Not backed up").unwrap()..];
1469
    assert!(missing.contains("href=\"/bob\"") && missing.contains("1 repository"), "{missing}");
1470
    assert!(get(app, "/admin", Some(&andy)).await.body.contains("/admin/backups"));
1471
    // A repository chooses its own (BP3): its owners decide.
1472
    let game = get(app, "/bob/game/settings/backup", Some(&bob)).await;
1473
    assert!(game.body.contains("bob has no backup set up yet") && game.body.contains(r#"name="backup" value="" checked"#), "{}", game.body);
1474
    let csrf = csrf_of(&game.body);
1475
    let saved = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=off")).await;
1476
    let saved = settled(app, saved, Some(&bob), "/bob/game/settings/backup").await;
1477
    assert!(saved.body.contains("left out of its owner"), "{}", saved.body);
1478
    assert_eq!(db::repos::get(&app.db, "bob", "game").await.unwrap().unwrap().backup, "off");
1479
    let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await;
1480
    let bad = settled(app, bad, Some(&bob), "/bob/game/settings/backup").await;
1481
    assert!(bad.body.contains("Pick how this repository is backed up."));
1482
}
1483
1484
/// oxygenhub#183: an owner hears about their repository by default, until
1485
/// they mute it; what an agent does with a token its owner marked is told
1486
/// to that owner; the administrators hear of each new account.
1487
#[tokio::test(flavor = "multi_thread")]
1488
async fn owners_hear_by_default_and_agents_tell_their_owner() {
1489
    let h = hub().await;
1490
    let app = &h.app;
1491
    let andy = register(app, "andy").await;
1492
    let bob = register(app, "bob").await;
1493
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1494
    let kinds = |id: i64| {
1495
        let db = app.db.clone();
1496
        async move { db::notify::inbox(&db, id, 50).await.unwrap().into_iter().map(|n| (n.kind, n.title, n.actor)).collect::<Vec<_>>() }
1497
    };
1498
    assert!(kinds(andy_id).await.iter().any(|(k, t, _)| k == "account.registered" && t.contains("bob")), "the administrator hears of bob");
1499
    let repo = RepoName::parse("andy/demo").unwrap();
1500
    app.repos.create(&repo, None).unwrap();
1501
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1502
    let open = |who: &'static str, title: &'static str| {
1503
        let (app, cookie) = (app.clone(), if who == "bob" { bob.clone() } else { andy.clone() });
1504
        async move {
1505
            let csrf = csrf_of(&get(&app, "/andy/demo/issues/new", Some(&cookie)).await.body);
1506
            post(&app, "/andy/demo/issues/new", Some(&cookie), &format!("_csrf={csrf}&title={title}&body=x")).await
1507
        }
1508
    };
1509
    // Andy never pressed Watch, but owns it: he hears of bob's issue.
1510
    assert!(get(app, "/andy/demo", Some(&andy)).await.body.contains("UNWATCH"), "an owner watches by default");
1511
    open("bob", "First").await;
1512
    assert!(kinds(andy_id).await.iter().any(|(k, t, _)| k == "issue.opened" && t.contains("First")));
1513
    // Muted, he does not.
1514
    let csrf = csrf_of(&get(app, "/andy/demo", Some(&andy)).await.body);
1515
    post(app, "/andy/demo/watch", Some(&andy), &format!("_csrf={csrf}")).await;
1516
    assert!(get(app, "/andy/demo", Some(&andy)).await.body.contains(">WATCH<"), "muted");
1517
    open("bob", "Second").await;
1518
    assert!(!kinds(andy_id).await.iter().any(|(_, t, _)| t.contains("Second")));
1519
    post(app, "/andy/demo/watch", Some(&andy), &format!("_csrf={csrf}")).await;
1520
1521
    // His own doing is not told to him; his agent's is.
1522
    let token = |agent: bool| {
1523
        let db = app.db.clone();
1524
        async move {
1525
            let (t, hash) = oxygenhub::auth::new_token();
1526
            db::accounts::create_token(&db, andy_id, if agent { "Claude" } else { "laptop" }, &hash, "repo:read,repo:write", "", None)
1527
                .await
1528
                .unwrap();
1529
            if agent {
1530
                let id = db::accounts::token_by_hash(&db, &hash).await.unwrap().unwrap().id;
1531
                db::accounts::set_token_agent(&db, andy_id, id, true).await.unwrap();
1532
            }
1533
            t
1534
        }
1535
    };
1536
    let api_issue = |t: String, title: &'static str| {
1537
        let app = app.clone();
1538
        async move {
1539
            use tower::ServiceExt;
1540
            let req = axum::http::Request::post("/api/v1/repos/andy/demo/issues")
1541
                .header("authorization", format!("Bearer {t}"))
1542
                .header("content-type", "application/json")
1543
                .body(axum::body::Body::from(serde_json::json!({ "title": title, "body": "" }).to_string()))
1544
                .unwrap();
1545
            oxygenhub::router(app).oneshot(req).await.unwrap().status()
1546
        }
1547
    };
1548
    assert_eq!(api_issue(token(false).await, "By hand").await, StatusCode::CREATED);
1549
    assert!(!kinds(andy_id).await.iter().any(|(_, t, _)| t.contains("By hand")), "his own doing");
1550
    assert_eq!(api_issue(token(true).await, "By the agent").await, StatusCode::CREATED);
1551
    let told = kinds(andy_id).await;
1552
    let agent = told.iter().find(|(_, t, _)| t.contains("By the agent")).expect("told of his agent's issue");
1553
    assert_eq!(agent.2, "andy (agent token Claude)");
1554
    // The tokens page marks and unmarks one.
1555
    let page = get(app, "/settings/tokens", Some(&andy)).await;
1556
    assert!(page.body.contains("Not an agent") && page.body.contains("Mark as agent"), "{}", page.body);
1557
    assert!(page.body.contains(r#"<span class="pill agent-pill""#), "the agent's token says so");
1558
}
1559
1560
/// #325: a card whose issue closes, or whose proposal lands or closes,
1561
/// moves to its board's column for finished work, Done to begin with.
1562
#[tokio::test(flavor = "multi_thread")]
1563
async fn finished_work_moves_to_its_column() {
1564
    let h = hub().await;
1565
    let app = &h.app;
1566
    let andy = register(app, "andy").await;
1567
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
1568
    let row = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1569
    let mut issues = vec![];
1570
    for t in ["closed here", "closed by a change", "stays open", "the column is off"] {
1571
        let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: row.id, title: t, body: "", author_id: None, author: "andy" })
1572
            .await
1573
            .unwrap();
1574
        issues.push(i);
1575
    }
1576
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
1577
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1578
    let cols: Vec<(i64, String, bool)> =
1579
        sqlx::query_as("SELECT id, name, takes_finished FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap();
1580
    assert_eq!(cols.iter().map(|c| c.2).collect::<Vec<_>>(), [false, false, true], "a new board's Done takes finished work");
1581
    let (backlog, doing, done) = (cols[0].0, cols[1].0, cols[2].0);
1582
    let act = |form: String| {
1583
        let andy = andy.clone();
1584
        async move { post(app, "/projects/andy/1", Some(&andy), &form).await }
1585
    };
1586
    for (i, col) in issues.iter().zip([backlog, doing, backlog, backlog]) {
1587
        act(format!("_csrf={csrf}&action=add&column={col}&reference=demo%23{}", i.number)).await;
1588
    }
1589
    let column_of = |n: i64| async move {
1590
        sqlx::query_scalar::<_, i64>("SELECT column_id FROM project_cards WHERE number = $1").bind(n).fetch_one(&app.db).await.unwrap()
1591
    };
1592
    let page = get(app, "/projects/andy/1", Some(&andy)).await.body;
1593
    assert!(page.contains("Finished work comes here") && page.contains("MOVE IT HERE"), "{page}");
1594
1595
    // Closed on its page, and by a change's `closes #N` (the same call).
1596
    let issue = format!("/andy/demo/issue/{}", issues[0].number);
1597
    let icsrf = csrf_of(&get(app, &issue, Some(&andy)).await.body);
1598
    post(app, &format!("{issue}/state"), Some(&andy), &format!("_csrf={icsrf}&action=close")).await;
1599
    assert_eq!(column_of(issues[0].number).await, done, "closed on its page");
1600
    db::issues::set_state(&app.db, issues[1].id, "closed", "completed").await.unwrap();
1601
    assert_eq!(column_of(issues[1].number).await, done, "from Doing too");
1602
    assert_eq!(column_of(issues[2].number).await, backlog, "an open issue stays");
1603
1604
    // Off: nothing moves; Backlog can take it instead.
1605
    let r = act(format!("_csrf={csrf}&action=takes_finished&column={done}&mode=off")).await;
1606
    assert!(r.body.contains("Cards stay where they are"), "{}", r.body);
1607
    db::issues::set_state(&app.db, issues[3].id, "closed", "not_planned").await.unwrap();
1608
    assert_eq!(column_of(issues[3].number).await, backlog);
1609
    let r = act(format!("_csrf={csrf}&action=takes_finished&column={doing}&mode=on")).await;
1610
    assert!(r.body.contains("Cards move to Doing when their issue closes"), "{}", r.body);
1611
    let flags: Vec<bool> =
1612
        sqlx::query_scalar("SELECT takes_finished FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap();
1613
    assert_eq!(flags, [false, true, false], "one column of a board");
1614
}