OXYGENHUB
◆ v0.9.23 oxygenhub / crates / hub / tests / starter.rs
10.4 KiB · Rust · last changed An assistant drafts a new repository's starting files; your Flow runs over the API; 0.9.13 (closes #347, refs andymccall/oxygenhub-android#26) 8 days ago source blame history raw
3d086bc3 Andy McCall ↶ 1
//! A new repository's starting files drafted by an assistant (#347): asked
2
//! for over the API, drafted in the background by the owner's assistant,
3
//! committed to the empty repository as the person who asked (the hub as
4
//! their delegate), never over files that are there, and refused plainly
5
//! when no assistant is set up or the person has not delegated to the hub.
6
7
use axum::body::Body;
8
use axum::http::{header, Request, StatusCode};
9
use http_body_util::BodyExt;
10
use oxy_core::repo::{IdentityScope, Repo};
11
use oxygenhub::config::Config;
12
use oxygenhub::db;
13
use oxygenhub::Shared;
14
use std::path::Path;
15
use tower::ServiceExt;
16
17
fn hermetic() {
18
    static ONCE: std::sync::Once = std::sync::Once::new();
19
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
20
    ONCE.call_once(|| {
21
        let d = tempfile::tempdir().unwrap();
22
        std::env::set_var("OXY_HOME", d.path());
23
        DIR.set(d).ok();
24
    });
25
}
26
27
struct Hub {
28
    app: Shared,
29
    _dir: tempfile::TempDir,
30
}
31
32
async fn hub() -> Hub {
33
    hermetic();
34
    let dir = tempfile::tempdir().unwrap();
35
    let mut config = Config::load(dir.path()).unwrap();
36
    config.registration.setup_code = "test-setup".into();
37
    config.registration.mode = "open".into();
38
    // The stand-in model is on this machine.
39
    config.outbound.allow = vec!["http://127.0.0.1".into()];
40
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
41
    let port = listener.local_addr().unwrap().port();
42
    config.server.base_url = format!("http://127.0.0.1:{port}");
43
    let app = oxygenhub::build(config).await.unwrap();
44
    let router = oxygenhub::router(app.clone());
45
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
46
    Hub { app, _dir: dir }
47
}
48
49
struct Res {
50
    status: StatusCode,
51
    body: String,
52
    cookie: Option<String>,
53
}
54
55
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, token: Option<&str>, body: Option<(&str, String)>) -> Res {
56
    let mut req = Request::builder().method(method).uri(path);
57
    if let Some(c) = cookie {
58
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
59
    }
60
    if let Some(t) = token {
61
        req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
62
    }
63
    let body = match body {
64
        Some((ct, b)) => {
65
            req = req.header(header::CONTENT_TYPE, ct);
66
            Body::from(b)
67
        }
68
        None => Body::empty(),
69
    };
70
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
71
    let status = res.status();
72
    let cookie = res
73
        .headers()
74
        .get(header::SET_COOKIE)
75
        .and_then(|v| v.to_str().ok())
76
        .and_then(|v| v.split(';').next())
77
        .and_then(|kv| kv.split_once('='))
78
        .map(|(_, v)| v.to_string());
79
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
80
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
81
}
82
83
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
84
    send(app, "POST", path, cookie, None, Some(("application/x-www-form-urlencoded", form.to_string()))).await
85
}
86
87
async fn api(app: &Shared, method: &str, path: &str, token: Option<&str>, json: Option<serde_json::Value>) -> Res {
88
    send(app, method, &format!("/api/v1{path}"), None, token, json.map(|j| ("application/json", j.to_string()))).await
89
}
90
91
async fn register(app: &Shared, user: &str) -> String {
92
    let r = post(
93
        app,
94
        "/register",
95
        None,
96
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
97
    )
98
    .await;
99
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
100
    r.cookie.unwrap()
101
}
102
103
/// `user`'s identity, delegating to the hub's key and linked to the
104
/// account, as web edits need; the client checkout that holds it.
105
async fn delegate(app: &Shared, user: &str, work: &Path) -> Repo {
106
    let id = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap().id;
107
    let mut client = Repo::init(work).unwrap();
108
    client.create_identity(user, &format!("{user}@example.com"), IdentityScope::Repo).unwrap();
109
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
110
    let key = client.config.author_key.unwrap();
111
    let ident = client.identity(&key).unwrap();
112
    db::accounts::link_identity(&app.db, id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap();
113
    client
114
}
115
116
/// A stand-in model: files for any request, but no files when asked to make-no-files.
117
async fn model() -> String {
118
    let router = axum::Router::new().route(
119
        "/v1/chat/completions",
120
        axum::routing::post(|axum::Json(req): axum::Json<serde_json::Value>| async move {
121
            let asked = req.to_string();
122
            let content = if asked.contains("make-no-files") {
123
                "Sure! I can help with that.".to_string()
124
            } else {
125
                "<<<FILE README.md>>>\n# Pong\n\nA small pong game.\n<<<END>>>\n<<<FILE src/main.rs>>>\nfn main() {}\n<<<END>>>\n"
126
                    .to_string()
127
            };
128
            axum::Json(serde_json::json!({ "choices": [{ "message": { "content": content } }] }))
129
        }),
130
    );
131
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
132
    let port = listener.local_addr().unwrap().port();
133
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
134
    format!("http://127.0.0.1:{port}/v1/chat/completions")
135
}
136
137
async fn admin_token(app: &Shared, user: &str) -> String {
138
    let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap();
139
    let (secret, hash) = oxygenhub::auth::new_token();
140
    db::accounts::create_token(&app.db, me.id, "phone", &hash, "repo:read,repo:write,repo:admin", "", None).await.unwrap();
141
    secret
142
}
143
144
async fn wait_done(app: &Shared, repo: &str, token: &str) -> serde_json::Value {
145
    for _ in 0..100 {
146
        let r = api(app, "GET", &format!("/repos/{repo}/starter"), Some(token), None).await;
147
        let v: serde_json::Value = serde_json::from_str(&r.body).unwrap();
148
        if v["status"] != "running" {
149
            return v;
150
        }
151
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
152
    }
153
    panic!("the starter for {repo} is still running");
154
}
155
156
#[tokio::test(flavor = "multi_thread")]
157
async fn an_assistant_drafts_an_empty_repositorys_starting_files() {
158
    let h = hub().await;
159
    let app = &h.app;
160
    register(app, "andy").await;
161
    let work = tempfile::tempdir().unwrap();
162
    delegate(app, "andy", work.path()).await;
163
    let t = admin_token(app, "andy").await;
164
    let r = api(app, "POST", "/repos", Some(&t), Some(serde_json::json!({ "owner": "andy", "name": "pong" }))).await;
165
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.body);
166
167
    // No assistant yet: none offered, and asking says where to set one up.
168
    let r = api(app, "GET", "/user/starter?owner=andy", Some(&t), None).await;
169
    assert_eq!(r.body, r#"{"assistants":[]}"#);
170
    let ask = serde_json::json!({ "description": "A pong game in Rust" });
171
    let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask.clone())).await;
172
    assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body);
173
    assert!(r.body.contains("no assistant is set up"), "{}", r.body);
174
175
    // The site's assistant.
176
    let ep = model().await;
177
    let body = serde_json::json!({ "name": "Helper", "endpoint": ep, "api": "chat-completions", "model": "m1" });
178
    let r = api(app, "POST", "/admin/assistants", Some(&t), Some(body)).await;
179
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
180
    let r = api(app, "GET", "/user/starter?owner=andy", Some(&t), None).await;
181
    let v: serde_json::Value = serde_json::from_str(&r.body).unwrap();
182
    assert_eq!((v["assistants"][0]["name"].as_str(), v["assistants"][0]["from"].as_str()), (Some("Helper"), Some("the site")), "{v}");
183
184
    // Asked: it works in the background, then the files are the repository's first change.
185
    let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask.clone())).await;
186
    assert_eq!(r.status, StatusCode::ACCEPTED, "{}", r.body);
187
    let v = wait_done(app, "andy/pong", &t).await;
188
    assert_eq!(v["status"], "done", "{v}");
189
    assert_eq!(v["files"], serde_json::json!(["README.md", "src/main.rs"]));
190
    assert_eq!(v["assistant"], "Helper");
191
    let r = api(app, "GET", "/repos/andy/pong/file/main/README.md", Some(&t), None).await;
192
    assert!(r.body.contains("A small pong game."), "{}", r.body);
193
    let r = api(app, "GET", "/repos/andy/pong/history/main", Some(&t), None).await;
194
    assert!(r.body.contains("Starter files drafted by Helper (m1)") && r.body.contains("A pong game in Rust"), "{}", r.body);
195
196
    // Never over files that are there, and the request must say something.
197
    let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask)).await;
198
    assert_eq!(r.status, StatusCode::CONFLICT, "{}", r.body);
199
    api(app, "POST", "/repos", Some(&t), Some(serde_json::json!({ "owner": "andy", "name": "empty" }))).await;
200
    let r = api(app, "POST", "/repos/andy/empty/starter", Some(&t), Some(serde_json::json!({ "description": "  " }))).await;
201
    assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body);
202
203
    // An answer with no files fails, saying so, and the repository stays empty.
204
    let r =
205
        api(app, "POST", "/repos/andy/empty/starter", Some(&t), Some(serde_json::json!({ "description": "make-no-files please" }))).await;
206
    assert_eq!(r.status, StatusCode::ACCEPTED, "{}", r.body);
207
    let v = wait_done(app, "andy/empty", &t).await;
208
    assert_eq!(v["status"], "failed", "{v}");
209
    assert!(v["error"].as_str().unwrap().contains("without any files"), "{v}");
210
    assert_eq!(api(app, "GET", "/repos/andy/empty/starter", None, None).await.status, StatusCode::OK, "readable as the repository is");
211
212
    // Someone who has not delegated to the hub is told how.
213
    register(app, "bob").await;
214
    let b = admin_token(app, "bob").await;
215
    assert_eq!(
216
        api(app, "GET", "/user/starter?owner=andy", Some(&b), None).await.body,
217
        r#"{"assistants":[]}"#,
218
        "not an owner bob creates under"
219
    );
220
    api(app, "POST", "/repos", Some(&b), Some(serde_json::json!({ "owner": "bob", "name": "game" }))).await;
221
    let r = api(app, "POST", "/repos/bob/game/starter", Some(&b), Some(serde_json::json!({ "description": "A game" }))).await;
222
    assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body);
223
    assert!(r.body.contains("delegated"), "{}", r.body);
224
}