Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/mock-upcloud/src/render.rs

//! **The wire shapes — including the inconsistencies, which are the point.**
//!
//! UpCloud stringifies numbers inconsistently: a storage's `size` is a JSON
//! number, a server's `core_number` and `memory_amount` are strings, a device's
//! `storage_size` is a number in one place and a string in the next, and a stop
//! request's `timeout` is a string that is accepted-and-ignored when sent as a
//! number. `monetize-cloud-impl`'s `Num` exists only because of this. A mock
//! that rendered everything as a number would let a client that cannot read
//! `"10"` pass, and that client would then fail at the provider.
//!
//! So the rendering here is hand-built `serde_json` rather than derived structs,
//! and every place the real API sends a string, this sends a string.
//!
//! # Two label shapes, and they are not the same shape
//!
//! A storage carries `"labels": [{key,value}]`. A server carries
//! `"labels": {"label": [{key,value}]}`. Both are filtered with
//! `?label=key%3Dvalue`. This is not a simplification anybody would invent; it
//! is what the API does, and a client written against one shape breaks on the
//! other.
//!
//! # Behaviour 4: the list is not the detail
//!
//! `GET /1.3/server` carries **no `storage_devices`, no `ip_addresses`** — those
//! exist only in `GET /1.3/server/{uuid}`. `GET /1.3/storage` DOES carry
//! `labels`. A caller that lists servers and reads attachments off the rows gets
//! an empty list and concludes there is nothing attached.
//!
//! # `created` IS sent — behaviour 10 as first written was wrong
//!
//! MEASURED against the live account 2026-09-20, two volumes, both endpoints:
//! `GET /1.3/storage/private` and `GET /1.3/storage/{uuid}` each carried
//! `"created"`. This crate previously omitted it from both and claimed the
//! provider did too, which made a young volume and an old orphan the same row —
//! a verdict path the real account cannot produce.
//!
//! `with_created` is now true unless [`crate::Fault::WithholdCreatedField`] is
//! armed by name. What behaviour 9 actually turns on is the TITLE, not the
//! date: the `Resize Backup` carries no product name, and the label is what
//! recognises it.

use crate::estate::{Estate, Server, Storage, StorageKind};
use serde_json::{json, Value};

pub fn labels_flat(s: &Storage) -> Value {
    Value::Array(s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect())
}

pub fn labels_enveloped(s: &Server) -> Value {
    json!({ "label": s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect::<Vec<_>>() })
}

/// One storage row. `with_created` is the fault; `detail` adds the `servers`
/// envelope that only `GET /1.3/storage/{uuid}` carries.
pub fn storage(e: &Estate, s: &Storage, detail: bool, with_created: bool) -> Value {
    let mut v = json!({
        "uuid": s.uuid,
        "title": s.title,
        // A NUMBER here…
        "size": s.size_gib,
        "state": s.state,
        "tier": s.tier,
        "type": s.kind.as_str(),
        "zone": s.zone,
        "access": if s.kind == StorageKind::Template { "public" } else { "private" },
        "labels": labels_flat(s),
    });
    if let Some(o) = &s.origin {
        v["origin"] = json!(o);
    }
    if with_created {
        v["created"] = json!(iso8601(s.created_ms));
    }
    if let Some(im) = &s.import {
        // A caller that reads the storage row can see that an import HAPPENED,
        // but its `state` is the import's, not the volume's. Both are here, and
        // they disagree for a hundred seconds after every upload.
        v["storage_import"] = import(im);
    }
    if detail {
        let servers = e.attached_servers(&s.uuid);
        v["servers"] = json!({ "server": servers });
        v["license"] = json!(0);
        v["backup_rule"] = s.backup_rule.clone().unwrap_or_else(|| json!({}));
    }
    v
}

/// One server row.
///
/// `detail` is behaviour 4: `storage_devices`, `ip_addresses`, `boot_order`,
/// `remote_access_*` and the plan's core/memory strings exist ONLY here. The
/// list row is deliberately thin, and a caller that reads an attachment off it
/// gets nothing.
pub fn server(s: &Server, detail: bool, with_created: bool) -> Value {
    let mut v = json!({
        "uuid": s.uuid,
        "title": s.title,
        "hostname": s.hostname,
        "plan": s.plan,
        "zone": s.zone,
        "state": s.state,
        // …and a STRING here. Same API, same object graph.
        "core_number": cores_of(&s.plan).to_string(),
        "memory_amount": memory_of(&s.plan).to_string(),
        "labels": labels_enveloped(s),
        "license": 0,
    });
    if with_created {
        v["created"] = json!(iso8601(s.created_ms));
    }
    if !detail {
        return v;
    }
    v["boot_order"] = json!(s.boot_order.as_str());
    v["remote_access_enabled"] = json!(if s.remote_access_enabled { "yes" } else { "no" });
    v["remote_access_type"] = json!("vnc");
    // **Behaviour 14, both halves.** The console is a HOST and a port, and the
    // reported pair is the pair from BEFORE the last stop/start. `reported_*`,
    // never the live fields — the difference between them is the defect, and
    // the host is in it too: MEASURED, the cure returned
    // `se-sto1.vnc.upcloud.com:60031`, a ZONE host and a five-figure port,
    // neither derivable from the server. A client that re-reads only the port
    // dials the right port at the wrong host.
    //
    // With remote access OFF the API reports NOTHING here rather than the last
    // known pair — a stale endpoint answered confidently is what makes a dead
    // console look alive.
    if s.remote_access_enabled {
        v["remote_access_host"] = json!(s.reported_vnc_host);
        v["remote_access_port"] = json!(s.reported_vnc_port.to_string());
        v["remote_access_password"] = json!(s.remote_access_password);
    }
    // Behaviour 15: SeaBIOS, and there is no other answer. UpCloud exposes no
    // firmware knob at all, which is why every gunnar ISO must carry a BIOS
    // boot path and why an OVMF-only image is unbootable here.
    v["firmware"] = json!("bios");
    // **The hypervisor says UTC, and it is telling the truth.** The appliance's
    // clock is still two hours out, because the guest's userland reads the RTC
    // as local time. A caller that trusts this field to mean "the guest's wall
    // clock is UTC" is drawing the wrong conclusion from a correct answer — and
    // that is exactly what happened.
    v["timezone"] = json!("UTC");
    v["storage_devices"] = json!({
        "storage_device": s.devices.iter().map(|d| json!({
            "address": d.address,
            "part_of_plan": "no",
            "storage": d.storage,
            // **A NUMBER, and the correction is worth more than the entry was
            // — behaviour 10's lesson, a second time.**
            //
            // This sent the STRING, on the reading that the API stringifies
            // `storage_size` "in one example and not the next" and that a
            // client which only handles numbers should fall over here rather
            // than at the provider. WITNESS, 2026-09-21: `UpCloudLtd/upcloud`
            // 5.44.1 — the provider UpCloud publishes, pointed at this mock
            // through `UPCLOUD_DEBUG_API_BASE_URL` — refuses it outright:
            //
            //   json: cannot unmarshal string into Go struct field
            //   …localServerDetails.storage_devices.storage_device.storage_size
            //   of type int
            //
            // Its own SDK declares the field `int`. A provider that cannot read
            // its own account is a provider nobody could ever have applied
            // terraform with, and holger's estate HAS been applied — so the
            // live API sends a number here and the string was this crate's.
            // Every server create against the mock failed on it, which is a
            // verdict path the account cannot produce: the exact mistake the
            // crate warns about at the top of `lib.rs`.
            //
            // The stringification that IS measured stays where it was measured:
            // a server's `core_number` and `memory_amount` above, and a stop
            // request's `timeout`. This field is not one of them.
            "storage_size": d.storage_size,
            "storage_title": d.storage_title,
            "storage_tier": "maxiops",
            "type": d.kind,
            "boot_disk": if d.boot_disk { "1" } else { "0" },
        })).collect::<Vec<_>>()
    });
    let mut rows = Vec::new();
    if !s.public_ip.is_empty() {
        rows.push(json!({"access": "public", "address": s.public_ip, "family": "IPv4"}));
    }
    rows.push(json!({"access": "utility", "address": s.utility_ip, "family": "IPv4"}));
    v["ip_addresses"] = json!({ "ip_address": rows });
    // Behaviour 61: an interface that ASKED for IPv6 gets one, on the flat list too.
    for i in s.ifaces.iter().filter(|i| i.kind == "public" && i.family == "IPv6") {
        if let Some(a) = v["ip_addresses"]["ip_address"].as_array_mut() {
            a.push(json!({"access": "public", "address": ipv6_of(s, i.index), "family": "IPv6"}));
        }
    }
    // **`networking` is `ip_addresses` said again, per INTERFACE, and the two
    // are not interchangeable.** `ip_addresses` is a flat list with an `access`
    // on each row; `networking.interfaces.interface` is the ordered list the
    // machine declared, and it is the one a terraform `network_interface[1]`
    // resolves against. holger's outputs read the appliance's SECOND interface
    // to scope the twin's only firewall rule, so an estate rendered without
    // this reads its utility address off nothing.
    v["networking"] = json!({
        "interfaces": {
            "interface": s.ifaces.iter().map(|i| {
                let v6 = i.kind == "public" && i.family == "IPv6";
                let addr = if v6 { ipv6_of(s, i.index) } else { address_of(s, &i.kind) };
                let family = if v6 { "IPv6" } else { "IPv4" };
                json!({
                    "index": i.index,
                    "type": i.kind,
                    "mac": mac_for(&s.uuid, i.index),
                    "network": "",
                    "bootable": "no",
                    "source_ip_filtering": "yes",
                    "ip_addresses": {"ip_address": [
                        {"address": addr, "family": family, "floating": "no"}
                    ]},
                })
            }).collect::<Vec<_>>()
        }
    });
    v["firewall"] = json!(if s.firewall_on { "on" } else { "off" });
    v["metadata"] = json!(if s.metadata { "yes" } else { "no" });
    // The timezone the machine ASKED for. See `Server::timezone`: the guest's
    // own clock lie is behaviour 19 and lives elsewhere.
    v["timezone"] = json!(s.timezone);
    v["simple_backup"] = json!(s.simple_backup);
    v["nic_model"] = json!("virtio");
    v["video_model"] = json!("vga");
    v["host"] = json!(0);
    v["server_group"] = json!("");
    v
}

/// Which of a server's two addresses an interface of this kind carries. A
/// `private` interface reads the utility address: this mock has one internal
/// wire, and inventing a second address pool would model a network nobody here
/// has measured.
fn address_of(s: &Server, kind: &str) -> String {
    match kind {
        "public" => s.public_ip.clone(),
        _ => s.utility_ip.clone(),
    }
}

/// A stable public IPv6 per (server, interface), in UpCloud's Stockholm prefix
/// shape. Derived, like the MAC, so a seed replays the same estate.
fn ipv6_of(s: &Server, index: u32) -> String {
    let m = mac_for(&s.uuid, index).replace(':', "");
    format!("2a04:3540:1000:310:{}:{}:{}:{}", &m[0..4], &m[4..8], &m[8..12], index)
}

/// A stable MAC per (server, interface). Derived rather than random so the same
/// seed replays the same estate, which is this crate's whole bargain.
pub(crate) fn mac_for(uuid: &str, index: u32) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in uuid.as_bytes().iter().chain(&index.to_le_bytes()) {
        h ^= *b as u64;
        h = h.wrapping_mul(0x100_0000_01b3);
    }
    format!(
        "0a:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
        (h >> 32) as u8,
        (h >> 24) as u8,
        (h >> 16) as u8,
        (h >> 8) as u8,
        h as u8
    )
}

/// The plan table. Small on purpose: these are the plans this estate buys, and
/// a plan name that is not here is refused by name rather than invented — an
/// invented plan would price at zero and make a spend cap untestable.
pub fn cores_of(plan: &str) -> u32 {
    // `DEV-2xCPU-4GB` is two cores, not one: the count is the digits directly
    // before the `x`, and a `DEV-` prefix sits in front of them. Reading the
    // whole first segment made every DEV plan a one-core plan, which is a
    // silently wrong number rather than a refusal.
    //
    // **MEASURED AGAIN 2026-09-21 (lane T3): `STARTER-` is a second prefix and
    // it did exactly the same thing.** `trim_start_matches("DEV-")` leaves
    // `STARTER-4xCPU-8GB` whole, `split('x').next()` answers `"STARTER-4"`,
    // that does not parse, and `unwrap_or(1)` makes gunnar's four-core
    // appliance a ONE-core machine — silently, with no refusal anywhere. A
    // named-prefix list is the shape that keeps being wrong, so the digits are
    // now read from where they actually are: the run directly before `xCPU`.
    let before = match plan.find("xCPU") {
        Some(i) => &plan[..i],
        None => return 1,
    };
    let mut digits: Vec<char> = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
    digits.reverse();
    digits.into_iter().collect::<String>().parse().unwrap_or(1)
}

pub fn memory_of(plan: &str) -> u32 {
    // `2xCPU-4GB` → 4096.
    plan.rsplit('-')
        .next()
        .and_then(|g| g.trim_end_matches("GB").parse::<u32>().ok())
        .map(|g| g * 1024)
        .unwrap_or(1024)
}

/// ★ **The plan table, ONCE.** `GET /1.3/plan` renders this list and
/// `POST /1.3/server` refuses anything not in it, so the endpoint can never
/// advertise a plan the create would then call `INVALID_PLAN`.
///
/// `DEV-2xCPU-4GB` is here because holger's APPLIANCE is that plan
/// (`private-holger-ops/estate.toml`, `server_plan`). It was missing while
/// `DEV-1xCPU-1GB` was present, so the mock would have refused the one machine
/// the whole estate is about — a refusal that reads as an estate defect and is
/// the mock's.
pub const PLANS: &[&str] = &[
    "1xCPU-1GB",
    "1xCPU-2GB",
    "2xCPU-4GB",
    "4xCPU-8GB",
    "6xCPU-16GB",
    "8xCPU-32GB",
    "DEV-1xCPU-1GB",
    "DEV-2xCPU-4GB",
    // **gunnar's three machines, added 2026-09-21 (lane T3).** `estate.toml`
    // buys `STARTER-4xCPU-8GB` (front), `STARTER-4xCPU-16GB` (appliance) and
    // `STARTER-1xCPU-1GB` (twin), and not one of them was in this list — so
    // terraform's own plan validator refused every gunnar server before a
    // single call reached the state machine. The same hole `DEV-2xCPU-4GB` was,
    // for the other estate: a refusal that reads as an estate defect and is the
    // mock's.
    "STARTER-1xCPU-1GB",
    // The twin since private-gunnar-ops dc846b5 (2026-09-27): the 1 GB twin
    // went silent under a fill on the account. Missing here, terraform's
    // validator refused the whole mock lay (MEASURED loki 2026-09-27, run S).
    "STARTER-1xCPU-2GB",
    // **Mini since 2026-10-13** (the owner: cheaper without breaking): the front is
    // `STARTER-2xCPU-4GB`, main `STARTER-4xCPU-8GB`, and the twin `STARTER-1xCPU-4GB` — 4 GB
    // is the twin's floor on every size (a 2 GB box cannot afford gunnar-server's
    // handle-cache floor). Missing here, the mock would refuse the smallest estate sold.
    "STARTER-1xCPU-4GB",
    "STARTER-2xCPU-4GB",
    "STARTER-4xCPU-8GB",
    "STARTER-4xCPU-16GB",
    // **The size ladder's bigger steps** (the sign-up page, 2026-10-07):
    // monetize's default ladder goes past the STARTER line into UpCloud's
    // General Purpose plans, Small to Gigantic. Missing here, every step past
    // Mini would read "the provider does not sell the plan" on the mock.
    "12xCPU-48GB",
    "16xCPU-64GB",
    "20xCPU-96GB",
    "20xCPU-128GB",
];

pub fn plan_known(plan: &str) -> bool {
    PLANS.contains(&plan)
}

/// `GET /1.3/price`.
///
/// The figures are UpCloud's for `se-sto1`, in **credits per hour** where one
/// credit is one euro cent. The storage tiers, the public address and the
/// STARTER plans an estate is priced from are the LIVE list's, read 2026-10-06
/// (`storage_maxiops` 0.031 credits/GB/h × 730 h = €0.2263 per GB-month, hdd
/// €0.05694, `ipv4_address` €3.51); the other plan rows are the older published
/// figures. `server_memory` is quoted per 256 MB.
pub fn price(zone: &str) -> Value {
    json!({
        "prices": {
            "zone": [{
                "name": zone,
                "server_core": {"amount": 1, "price": 1.3},
                "server_memory": {"amount": 256, "price": 0.45},
                "storage_maxiops": {"amount": 1, "price": 0.031},
                "storage_hdd": {"amount": 1, "price": 0.0078},
                "storage_standard": {"amount": 1, "price": 0.0118},
                "storage_backup": {"amount": 1, "price": 0.0078},
                // MEASURED on the live se-sto1 list 2026-10-05 17:30 UTC (read-only):
                // 0.4812 credits/h = EUR 3.51 a month per address. A private estate
                // carries three (front, main, twin), and its price counts them.
                "public_ipv4_address": {"amount": 1, "price": 0.4812},
                "ipv4_address": {"amount": 1, "price": 0.4812},
                "server_plan_1xCPU-1GB": {"amount": 1, "price": 0.744},
                "server_plan_1xCPU-2GB": {"amount": 1, "price": 1.24},
                "server_plan_2xCPU-4GB": {"amount": 1, "price": 2.48},
                "server_plan_4xCPU-8GB": {"amount": 1, "price": 4.96},
                "server_plan_6xCPU-16GB": {"amount": 1, "price": 9.92},
                "server_plan_8xCPU-32GB": {"amount": 1, "price": 19.84},
                "server_plan_DEV-1xCPU-1GB": {"amount": 1, "price": 0.372},
                // `DEV-2xCPU-4GB` was added to PLANS and never to the price
                // table, so holger's appliance priced at zero — the exact
                // failure the PLANS doc warns about, one table along.
                "server_plan_DEV-2xCPU-4GB": {"amount": 1, "price": 1.24},
                // gunnar's three (and the 1 GB STARTER): MEASURED on the live
                // se-sto1 list 2026-10-06 17:40 UTC (read-only GET /1.3/price),
                // EUR 3.26 / 6.52 / 21.73 / 30.42 a month. They were ESTIMATES
                // (€5 / €8.2 / €38 / €62) until then, and every price the room's
                // rule derived from this mock was ~1.7× the real estate's.
                "server_plan_STARTER-1xCPU-1GB": {"amount": 1, "price": 0.4464},
                "server_plan_STARTER-1xCPU-2GB": {"amount": 1, "price": 0.8928},
                // Mini's twin and front since 2026-10-13. `STARTER-2xCPU-4GB` is the
                // figure monetize-impl's fixtures already carry (1.7857 credits an hour);
                // `STARTER-1xCPU-4GB` is DERIVED from the owner's monthly figure for
                // se-sto1 that day (10.71 EUR over UpCloud's 720-hour month), not read
                // off the live list here.
                "server_plan_STARTER-1xCPU-4GB": {"amount": 1, "price": 1.4881},
                "server_plan_STARTER-2xCPU-4GB": {"amount": 1, "price": 1.7857},
                "server_plan_STARTER-4xCPU-8GB": {"amount": 1, "price": 2.9761},
                "server_plan_STARTER-4xCPU-16GB": {"amount": 1, "price": 4.1666},
                // The ladder's General Purpose steps: ESTIMATES on the same
                // scale as the older published rows above (each step the
                // core and memory price of the one before it, doubled where
                // the plan doubles) — not read off the live list.
                "server_plan_12xCPU-48GB": {"amount": 1, "price": 29.76},
                "server_plan_16xCPU-64GB": {"amount": 1, "price": 39.68},
                "server_plan_20xCPU-96GB": {"amount": 1, "price": 59.52},
                "server_plan_20xCPU-128GB": {"amount": 1, "price": 79.36}
            }]
        }
    })
}

/// **The direct-upload import object**, exactly as the live one answers.
///
/// MEASURED on the estate: `created 09:36:16Z`, `completed 09:36:21Z`,
/// `read_bytes == written_bytes == 43485184`, `md5sum` and `sha256sum` both
/// present, the sha256 matching the local file. `state` here is the IMPORT's
/// state and it is not the storage's — that is the two-clock behaviour, and the
/// reason a caller that polls only this one waits five seconds and then uses a
/// volume that is still `syncing`.
pub fn import(im: &crate::estate::Import) -> Value {
    let mut v = json!({
        "source": im.source,
        "state": im.state,
        "created": iso8601(im.created_ms),
        "uuid": "",
        "client_content_length": im.client_content_length,
        "read_bytes": im.read_bytes,
        "written_bytes": im.written_bytes,
    });
    if !im.direct_upload_url.is_empty() {
        v["direct_upload_url"] = json!(im.direct_upload_url);
    }
    if !im.source_location.is_empty() {
        v["source_location"] = json!(im.source_location);
    }
    if let Some(c) = im.completed_ms {
        v["completed"] = json!(iso8601(c));
    }
    // Behaviour 75 (MEASURED on the account 2026-10-17, test2's front-keep): the digests are
    // ALWAYS there, as `""` while the import is pending or importing — a caller that stops at
    // "sha256sum is a string" reads an empty digest and gives a good import up.
    v["md5sum"] = json!(im.md5sum.as_deref().unwrap_or(""));
    v["sha256sum"] = json!(im.sha256sum.as_deref().unwrap_or(""));
    if let Some(c) = &im.error_code {
        v["error_code"] = json!(c);
    }
    if let Some(m) = &im.error_message {
        v["error_message"] = json!(m);
    }
    v
}

/// The ORDINARY error envelope: `{"error": {"error_code", "error_message"}}`.
pub fn error(code: &str, message: &str) -> Value {
    json!({"error": {"error_code": code, "error_message": message}})
}

/// **Behaviour 1 — the other error envelope, and the whole reason it is here.**
///
/// A firewall-rule read for a server that does not exist (because it was
/// DELETED) answers `403` with a `problem+json` body naming
/// `ERROR_AUTHENTICATION_FAILED` and a `correlation_id`. Not 404. Not
/// `SERVER_NOT_FOUND`.
///
/// Terraform cannot tell this from a revoked token, so it does not treat it as
/// "the resource is gone, remove it from state" — it STOPS. That cost the first
/// hour of 2026-09-20 and it will cost the next hour too, because nothing about
/// it is going to change at the provider. It is reproducible here on demand,
/// which is the difference between a defect you plan around and a defect you
/// rediscover.
pub fn auth_failed(correlation_id: &str) -> Value {
    json!({
        "type": "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED",
        "title": "Authentication failed using the given username and password.",
        "correlation_id": correlation_id,
        "status": 403
    })
}

/// The mock's own refusal for a path it does not implement. Loud, and it names
/// the path: an unimplemented call must never be able to read as a working one.
pub fn not_implemented(method: &str, path: &str) -> Value {
    json!({"error": {
        "error_code": "MOCK_UPCLOUD_NOT_IMPLEMENTED",
        "error_message": format!("mock-upcloud does not implement {method} {path}; it is not part of the surface this estate drives")
    }})
}

/// Virtual milliseconds as an RFC 3339 stamp. The epoch is arbitrary and fixed
/// (2026-01-01T00:00:00Z) so a replayed seed produces byte-identical stamps.
pub fn iso8601(ms: u64) -> String {
    const EPOCH_DAYS: u64 = 20454; // 2026-01-01 since 1970-01-01
    let secs = ms / 1000;
    let days = EPOCH_DAYS + secs / 86_400;
    let rem = secs % 86_400;
    let (y, m, d) = civil_from_days(days);
    format!(
        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
        rem / 3600,
        (rem % 3600) / 60,
        rem % 60
    )
}

/// Howard Hinnant's `civil_from_days`, so the stamp is a real date and not an
/// approximation. No chrono: the whole need is one conversion.
fn civil_from_days(z: u64) -> (u64, u64, u64) {
    let z = z + 719_468;
    let era = z / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    (if m <= 2 { y + 1 } else { y }, m, d)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_epoch_is_the_first_of_january() {
        assert_eq!(iso8601(0), "2026-01-01T00:00:00Z");
        assert_eq!(iso8601(86_400_000), "2026-01-02T00:00:00Z");
        assert_eq!(iso8601(105_000), "2026-01-01T00:01:45Z");
    }

    #[test]
    fn a_plan_is_read_into_cores_and_megabytes() {
        assert_eq!(cores_of("2xCPU-4GB"), 2);
        assert_eq!(memory_of("2xCPU-4GB"), 4096);
        assert_eq!(memory_of("8xCPU-32GB"), 32768);
    }

    /// gunnar's twin is `STARTER-1xCPU-2GB` since private-gunnar-ops dc846b5
    /// (the 1 GB twin went silent under a fill on the account). The mock must
    /// sell it, and price it, or terraform's validator refuses the whole lay:
    /// MEASURED loki 2026-09-27 (mock run S), "expected plan to be one of
    /// [...], got STARTER-1xCPU-2GB".
    #[test]
    fn gunnars_two_gigabyte_twin_is_sold_and_priced() {
        assert!(plan_known("STARTER-1xCPU-2GB"));
        assert_eq!(cores_of("STARTER-1xCPU-2GB"), 1);
        assert_eq!(memory_of("STARTER-1xCPU-2GB"), 2048);
        let p = price("se-sto1");
        let credits = p["prices"]["zone"][0]["server_plan_STARTER-1xCPU-2GB"]["price"].as_f64();
        assert!(credits.is_some_and(|c| c > 0.685), "priced above the 1 GB twin: {credits:?}");
    }

    /// ★ **Mini's three machines are sold and priced** (owner, 2026-10-13): front
    /// `STARTER-2xCPU-4GB`, main `STARTER-4xCPU-8GB`, twin `STARTER-1xCPU-4GB` — 12.86,
    /// 21.43 and 10.71 EUR over UpCloud's 720-hour month at se-sto1, 45.00 together (the
    /// old Mini's three were 57.86: 21.43 + 30.00 + 6.43).
    #[test]
    fn minis_three_machines_are_sold_and_priced() {
        let p = price("se-sto1");
        let z = &p["prices"]["zone"][0];
        let month_720 = |plan: &str| z[format!("server_plan_{plan}").as_str()]["price"].as_f64().unwrap_or_else(|| panic!("{plan} has no price row")) * 720.0 / 100.0;
        for (plan, cores, mib, eur) in [("STARTER-2xCPU-4GB", 2, 4096, 12.86), ("STARTER-4xCPU-8GB", 4, 8192, 21.43), ("STARTER-1xCPU-4GB", 1, 4096, 10.71)] {
            assert!(plan_known(plan), "{plan} is not in the plan table");
            assert_eq!((cores_of(plan), memory_of(plan)), (cores, mib), "{plan}");
            assert!((month_720(plan) - eur).abs() < 0.01, "{plan}: {} EUR a month, the owner's figure is {eur}", month_720(plan));
        }
        let mini: f64 = ["STARTER-2xCPU-4GB", "STARTER-4xCPU-8GB", "STARTER-1xCPU-4GB"].iter().map(|p| month_720(p)).sum();
        let old: f64 = ["STARTER-4xCPU-8GB", "STARTER-4xCPU-16GB", "STARTER-1xCPU-2GB"].iter().map(|p| month_720(p)).sum();
        assert!((mini - 45.00).abs() < 0.02 && (old - 57.86).abs() < 0.02, "{mini} / {old}");
    }

    /// ★ **The live se-sto1 list, pinned** (cov-D 2026-10-06: read read-only
    /// from UpCloud's `GET /1.3/price` at 17:40 UTC, credits per hour): the
    /// mock's STARTER plans were ESTIMATES (38 / 62 / 8.20 EUR a month against
    /// live 21.73 / 30.42 / 6.52) and its maxiops 0.20 EUR/GB-month against
    /// live 0.2263, so every price the room's rule derived from the mock was
    /// ~1.7× the real estate's. The rows an estate is priced from now carry
    /// the live figures.
    #[test]
    fn the_rows_an_estate_is_priced_from_are_the_live_se_sto1_list_of_2026_10_06() {
        let p = price("se-sto1");
        let z = &p["prices"]["zone"][0];
        for (item, credits_per_hour) in [
            ("server_plan_STARTER-1xCPU-1GB", 0.4464),
            ("server_plan_STARTER-1xCPU-2GB", 0.8928),
            ("server_plan_STARTER-4xCPU-8GB", 2.9761),
            ("server_plan_STARTER-4xCPU-16GB", 4.1666),
            ("storage_maxiops", 0.031),
            ("storage_hdd", 0.0078),
            ("storage_standard", 0.0118),
            ("storage_backup", 0.0078),
            ("ipv4_address", 0.4812),
            ("public_ipv4_address", 0.4812),
        ] {
            assert_eq!(z[item]["price"].as_f64(), Some(credits_per_hour), "{item}");
        }
        // As EUR a month (730 h): 21.73 / 30.42 / 6.52, maxiops 0.2263, hdd 0.05694, IPv4 3.51.
        let month = |item: &str| z[item]["price"].as_f64().unwrap() * 730.0 / 100.0;
        assert!((month("server_plan_STARTER-4xCPU-8GB") - 21.73).abs() < 0.01);
        assert!((month("server_plan_STARTER-4xCPU-16GB") - 30.42).abs() < 0.01);
        assert!((month("server_plan_STARTER-1xCPU-2GB") - 6.52).abs() < 0.01);
        assert!((month("storage_maxiops") - 0.2263).abs() < 0.0001);
        assert!((month("storage_hdd") - 0.05694).abs() < 0.00001);
        assert!((month("ipv4_address") - 3.51).abs() < 0.01);
    }

    /// The price row is the one a spend cap is measured against: maxiops at
    /// €0.2263 per GB-month (the live list, 2026-10-06; it was ~€0.20). If this
    /// arithmetic changes, every cap test that ever passed was measuring
    /// something else.
    #[test]
    fn maxiops_is_twenty_two_point_six_cents_a_gigabyte_month() {
        let p = price("se-sto1");
        let credits_per_gb_hour = p["prices"]["zone"][0]["storage_maxiops"]["price"].as_f64().unwrap();
        let cents_per_gb_month = credits_per_gb_hour * 730.0;
        assert!((cents_per_gb_month - 22.63).abs() < 0.01, "{cents_per_gb_month}");
    }

    #[test]
    fn the_auth_failed_body_is_the_one_terraform_stops_on() {
        let v = auth_failed("abc123");
        assert_eq!(
            v["type"],
            "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED"
        );
        assert_eq!(v["correlation_id"], "abc123");
        assert!(v["error"].is_null(), "it is problem+json, not the error envelope");
    }
}