Gunnar

nordisk/gunnar-ui

uitest/src/addusers.rs

//! **"Add users"** (owner, 2026-10-04) — the account admin's dialog, driven
//! as a person drives it, and held against the SERVER's answer.
//!
//! The dialog starts at ONE user and 10 GB of room, and asks a name only for
//! one user. A user that fits in the account's unused room is added at once,
//! with an invite to sign in. Where the console sells room (the bench
//! monetize, the FAKE Stripe), an account short of room is sent to ONE
//! hosted checkout; when the vendor says paid, the seats exist on gunnar, the
//! order page reads "queued" and then "done" from the server — the
//! enlargement is queued, never done inline — and an expired checkout adds
//! nobody.
//!
//! `person` is kim's session: a viewer of this console, and the admin of
//! kim's own account (`/kim`).

use std::time::Duration;

use crate::cdp::Cdp;
use crate::http;
use crate::json::esc;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;

const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";

/// Set an input as a person typing would (the value, then the `input` event
/// the page listens to).
fn type_into(c: &mut Cdp, sess: &str, id: &str, value: &str) -> Result<(), String> {
    c.eval(
        sess,
        &format!(
            "(() => {{ const i = document.getElementById({}); if (!i) return false;
                const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
                set.call(i, {}); i.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()",
            esc(id),
            esc(value)
        ),
    )?;
    Ok(())
}

/// The account the session is the admin of, as the server names it.
fn own_account(c: &mut Cdp, sess: &str) -> Result<String, String> {
    let j = c.eval(sess, "fetch('/api/account/room').then(r => r.json())")?;
    let ns = j.text("namespace");
    want(!ns.is_empty(), || format!("the server names no account for this session: {}", http::cut(&j.dump(), 200)))?;
    Ok(ns)
}

/// The account's employees and seats, as gunnar answers them.
fn employees(c: &mut Cdp, sess: &str, account: &str) -> Result<crate::json::Json, String> {
    c.eval(sess, &format!("fetch('/api/account/employees?account=' + encodeURIComponent({})).then(r => r.json())", esc(account)))
}

fn number(j: &crate::json::Json, p: &str) -> u64 {
    j.path(p).and_then(|v| v.as_f64()).unwrap_or(-1.0) as u64
}

pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, person: &str) {
    let mut account = String::new();
    go(rows, "the-add-users-dialog-starts-at-one-user-and-10-gb-and-asks-a-name-only-for-one", || {
        account = own_account(c, person)?;
        c.navigate(person, &format!("{}/{}", r.origin(), account))?;
        c.wait_for(person, "!!document.querySelector('#add-users #users-add')", 30_000)?;
        let count = c.eval_str(person, "document.getElementById('users-count').value")?;
        let room = c.eval_str(person, "document.getElementById('users-room').value")?;
        let named = c.eval(person, "!!document.getElementById('users-name')")?;
        want(count == "1" && room == "10", || format!("the dialog starts at {count} user(s) and {room} GB, not 1 and 10"))?;
        want(named.as_bool() == Some(true), || "no name is asked for ONE user".to_owned())?;
        type_into(c, person, "users-count", "3")?;
        c.wait_for(person, "!document.getElementById('users-name')", 10_000)
            .map_err(|_| "a name is still asked for three users".to_owned())?;
        type_into(c, person, "users-count", "1")?;
        c.wait_for(person, "!!document.getElementById('users-name')", 10_000)
            .map_err(|_| "back at one user, no name is asked".to_owned())?;
        Ok(format!("/{account}: 1 user, 10 GB; a name for one user only"))
    });

    go(rows, "one-named-user-is-added-at-once-holding-10-gb-with-an-invite", || {
        want(!account.is_empty(), || "no account (the row before failed)".to_owned())?;
        type_into(c, person, "users-count", "1")?;
        type_into(c, person, "users-room", "10")?;
        type_into(c, person, "users-name", "dora")?;
        c.eval(person, "(() => { document.getElementById('users-add').click(); return 1; })()")?;
        c.wait_for(person, "!!document.getElementById('users-added') || !!document.querySelector('#add-users .fail, .fail-box')", 30_000)?;
        let said = c.eval_str(person, "(document.getElementById('users-added') || {textContent: ''}).textContent")?;
        let invite = c.eval_str(person, "(document.getElementById('employee-invite') || {textContent: ''}).textContent")?;
        want(said.contains("dora is added, with 10 GB"), || format!("the page says {:?}; {}", said, http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        want(invite.contains("invite="), || format!("no invite for dora: {invite:?}"))?;
        let listed = employees(c, person, &account)?;
        let names: Vec<String> = listed.path("employees").map(|e| e.arr().iter().map(|p| p.text("id")).collect()).unwrap_or_default();
        want(names.iter().any(|n| n == "dora"), || format!("gunnar does not list dora: {}", http::cut(&listed.dump(), 200)))?;
        Ok(format!("{said} gunnar lists {names:?}"))
    });

    if r.bench_control.is_none() {
        for name in [
            "a-short-account-pays-once-at-the-fake-stripe-and-the-seats-come-with-the-room-queued-then-done",
            "an-expired-checkout-adds-nobody",
            "a-bought-seat-is-filled-from-the-console-with-a-new-user-and-their-invite",
            "a-user-whose-name-is-taken-during-the-checkout-is-refunded",
        ] {
            go(rows, name, || Err("NOT RUN: no --bench-monetize with a control door (the fake Stripe is what this row pays)".into()));
        }
        return;
    }

    go(rows, "a-short-account-pays-once-at-the-fake-stripe-and-the-seats-come-with-the-room-queued-then-done", || {
        want(!account.is_empty(), || "no account (the rows before failed)".to_owned())?;
        // The account holds 10 GiB (dora holds all of it): three more users of
        // 10 GB are 30 GiB short.
        let (code, said) = r.bench_post(&format!("/grant/{account}/10"))?;
        want(code == 200, || format!("the bench could not grant {account} 10 GiB: {code} {said}"))?;
        let before = employees(c, person, &account)?;
        c.navigate(person, &format!("{}/{}", r.origin(), account))?;
        c.wait_for(person, "!!document.querySelector('#add-users #users-add')", 30_000)?;
        type_into(c, person, "users-count", "3")?;
        type_into(c, person, "users-room", "10")?;
        c.call(Some(person), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#)?;
        c.events.clear();
        c.eval(person, "(() => { document.getElementById('users-add').click(); return 1; })()")?;
        let Some((id, url)) = c.paused_request(person, 30_000) else {
            let _ = c.call(Some(person), "Fetch.disable", "{}");
            return Err(format!("Add users did not go to the vendor within 30 s; the page says {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)));
        };
        let _ = c.call(Some(person), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
        let _ = c.call(Some(person), "Fetch.disable", "{}");
        let sessions: Vec<_> = c.events.iter().filter(|e| e.text("method") == "Fetch.requestPaused").collect();
        want(sessions.len() == 1, || format!("{} checkouts for one request", sessions.len()))?;
        let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
        want(cs.starts_with("cs_test_"), || format!("not a checkout session: {url}"))?;
        let none_yet = employees(c, person, &account)?;
        want(number(&none_yet, "seats") == number(&before, "seats"), || format!("seats before the money: {}", http::cut(&none_yet.dump(), 200)))?;

        // The customer pays; the vendor's notice reaches monetize.
        let (code, paid) = r.bench_post(&format!("/pay/{cs}"))?;
        want(code == 200 && paid.contains("200"), || format!("paying {cs}: {code} {paid}"))?;
        let reference = paid.lines().next().unwrap_or_default().trim_start_matches("reference=").to_owned();
        want(!reference.is_empty(), || format!("the bench named no order for {cs}: {paid}"))?;

        // Where the vendor's return lands: the order, followed from the server.
        let path = format!("{}/paid/{}", r.origin(), reference);
        c.navigate(person, &path)?;
        c.wait_for(person, "!!document.getElementById('room-progress')", 30_000)
            .map_err(|_| format!("no room progress on {path}: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)))?;
        let first = c.eval_str(person, "document.getElementById('room-progress').dataset.state")?;
        let first_words = c.eval_str(person, "document.getElementById('room-progress').textContent")?;
        want(first == "queued" || first == "enlarging", || format!("the paid order's room reads {first:?} ({first_words:?}), not queued"))?;
        // monetize's words since 2026-10-05: "Paid · queued for today's batch — your room is reserved …".
        want(first_words.starts_with("Paid") && first_words.contains("your room is reserved"), || format!("the buyer reads {first_words:?}"))?;
        c.wait_for(person, "document.getElementById('room-progress') && document.getElementById('room-progress').dataset.state === 'done'", 60_000)
            .map_err(|_| format!("the room never read done: {}", c.eval_str(person, "document.getElementById('room-progress').textContent").unwrap_or_default()))?;
        let done_words = c.eval_str(person, "document.getElementById('room-progress').textContent")?;
        let users = c.eval_str(person, "(document.getElementById('order-users') || {textContent: ''}).textContent")?;
        want(users.contains("3 seat(s) added"), || format!("the order page says {users:?}"))?;
        let after = employees(c, person, &account)?;
        want(number(&after, "seats") == number(&before, "seats") + 3, || format!("gunnar holds {} seats, wanted {} + 3: {}", number(&after, "seats"), number(&before, "seats"), http::cut(&after.dump(), 200)))?;
        Ok(format!("ONE checkout {cs}; paid → {first} ({first_words}) → done ({done_words}); {users}"))
    });

    go(rows, "an-expired-checkout-adds-nobody", || {
        let before = employees(c, person, &account)?;
        let started = c.eval(
            person,
            &format!(
                "fetch('/api/account/employees', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{account: {}, count: 2, room_gib: 50}})}}).then(r => r.json())",
                esc(&account)
            ),
        )?;
        let url = started.text("checkout_url");
        want(url.contains("cs_test_"), || format!("no checkout for 100 GiB more: {}", http::cut(&started.dump(), 300)))?;
        let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
        let (code, said) = r.bench_post(&format!("/expire/{cs}"))?;
        want(code == 200, || format!("expiring {cs}: {code} {said}"))?;
        std::thread::sleep(Duration::from_millis(500));
        let after = employees(c, person, &account)?;
        want(number(&after, "seats") == number(&before, "seats"), || format!("an expired checkout added seats: {}", http::cut(&after.dump(), 200)))?;
        Ok(format!("{cs} expired; seats stay {}", number(&after, "seats")))
    });

    go(rows, "a-bought-seat-is-filled-from-the-console-with-a-new-user-and-their-invite", || {
        let before = employees(c, person, &account)?;
        want(number(&before, "seats_open") > 0, || format!("no open seat to fill: {}", http::cut(&before.dump(), 200)))?;
        c.navigate(person, &format!("{}/{}", r.origin(), account))?;
        c.wait_for(person, "!!document.querySelector('#fill-seat #seat-fill')", 30_000)
            .map_err(|_| format!("no Fill-a-seat on /{account}: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        type_into(c, person, "seat-name", "sam")?;
        c.eval(person, "(() => { document.getElementById('seat-fill').click(); return 1; })()")?;
        c.wait_for(person, "!!document.getElementById('users-added') && document.getElementById('users-added').textContent.includes('sam')", 30_000)
            .map_err(|_| format!("the seat was not filled: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        let invite = c.eval_str(person, "(document.getElementById('employee-invite') || {textContent: ''}).textContent")?;
        want(invite.contains("invite=") && invite.contains("user=sam"), || format!("no invite for sam: {invite:?}"))?;
        let after = employees(c, person, &account)?;
        let names: Vec<String> = after.path("employees").map(|e| e.arr().iter().map(|p| p.text("id")).collect()).unwrap_or_default();
        want(names.iter().any(|n| n == "sam"), || format!("gunnar does not list sam: {}", http::cut(&after.dump(), 200)))?;
        want(number(&after, "seats_open") + 1 == number(&before, "seats_open"), || format!("no seat was taken: {} → {}", number(&before, "seats_open"), number(&after, "seats_open")))?;
        Ok(format!("sam took a seat ({} open now) with an invite", number(&after, "seats_open")))
    });

    go(rows, "a-user-whose-name-is-taken-during-the-checkout-is-refunded", || {
        // A named user on a short account: one checkout. While the customer is
        // at the vendor, somebody else takes the name.
        let started = c.eval(
            person,
            &format!(
                "fetch('/api/account/employees', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{account: {}, count: 1, room_gib: 50, name: 'quill'}})}}).then(r => r.json())",
                esc(&account)
            ),
        )?;
        let url = started.text("checkout_url");
        want(url.contains("cs_test_"), || format!("no checkout for quill: {}", http::cut(&started.dump(), 300)))?;
        let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
        let key = r.work.join("quill-key");
        let _ = std::fs::remove_file(&key);
        let made = std::process::Command::new("ssh-keygen")
            .current_dir(&r.work)
            .args(["-q", "-t", "ed25519", "-N", "", "-C", "quill", "-f"])
            .arg(&key)
            .output()
            .map_err(|e| format!("ssh-keygen: {e}"))?;
        want(made.status.success(), || "ssh-keygen failed".to_owned())?;
        r.control(&["user", "add", "quill", "--key", &format!("@{}", key.with_extension("pub").display())])?;
        let (code, paid) = r.bench_post(&format!("/pay/{cs}"))?;
        want(code == 200, || format!("paying {cs}: {code} {paid}"))?;
        let reference = paid.lines().next().unwrap_or_default().trim_start_matches("reference=").to_owned();
        c.navigate(person, &format!("{}/paid/{}", r.origin(), reference))?;
        c.wait_for(person, "!!document.getElementById('order-users') && document.getElementById('order-users').dataset.refunded === 'true'", 60_000)
            .map_err(|_| format!("the order was not refunded: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)))?;
        let said = c.eval_str(person, "document.getElementById('order-users').textContent")?;
        want(said.contains("refunded"), || format!("the buyer reads {said:?}"))?;
        Ok(format!("{cs}: {said}"))
    });
}