printobserver-sdk 0.10.2

Rust client for the printobserver server.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
//! The one journey all three clients drive, in Rust.
//!
//! Nine steps against a **real supervisor backed by a real `OctoPrint`** — the
//! instance `just octoprint-up` started, with its virtual printer. Nothing here
//! stands in for a layer: the client is the published one, the server is the
//! program this repository builds, and the machine is a real one.
//!
//! The same nine steps run in the Python and the Node clients, in the same
//! order, asserting the same normalized answers. Three clients running three
//! different journeys would prove three different products.
//!
//! # Four orderings are load-bearing
//!
//! The manifest is written before the print is started, so the print runs under
//! it. The accepted adjustment and the rejected one both happen after the print
//! has started and before history is read, so history has them to account for.
//! The print is cancelled last.
//!
//! # It puts the hold print back
//!
//! `just test-integration` runs this beside the scripted environment's own
//! suite, which asserts a print is there to be acted on. This walk cancels one
//! and starts one, so it leaves the machine where the bring-up left it.

#[path = "support/live.rs"]
mod live;
#[path = "support/supervisor.rs"]
mod supervisor;

use std::collections::BTreeMap;
use std::time::{Duration, Instant};

use printobserver_sdk::{
    ActionExecutedPayload, ActionRejectedPayload, ActionRequestedPayload, Actor, Client,
    ClientError, EventRecord, ExecutionOutcome, JobManifest, PolicyDecision, PrintAction,
    PrinterState, Range, RejectionReason,
};

/// The reason every mutating step of this walk carries.
const REASON: &str = "a printer-integration journey is asking";

/// How long a bounded adjustment stands for, in whole seconds.
const DURATION: i64 = 60;

/// The feedrate factor the accepted adjustment asks for, inside the envelope
/// the world is configured with.
const INSIDE: f64 = 1.1;

/// The feedrate factor the rejected adjustment asks for, outside it.
const OUTSIDE: f64 = 9.9;

/// How long the machine is given to reach a state a step needs.
const PATIENCE: Duration = Duration::from_secs(180);

/// The manifest this walk writes, which the print then runs under.
fn manifest(file_name: &str) -> JobManifest {
    let mut allowed = BTreeMap::new();
    allowed.insert("feedrate".to_owned(), Range { min: 0.9, max: 1.2 });
    JobManifest {
        file_name: file_name.to_owned(),
        material: "PLA".to_owned(),
        nozzle_diameter_mm: 0.4,
        slicer_profile: "a journey's own profile".to_owned(),
        allowed,
        metadata: BTreeMap::new(),
    }
}

/// Wait until the machine reports one of these states, and answer which.
///
/// # Panics
///
/// Panics when it does not, saying what it reported instead: a walk that went
/// on regardless would assert against a machine that was somewhere else.
fn until(client: &Client, print_id: &str, wanted: &[PrinterState]) -> PrinterState {
    let deadline = Instant::now() + PATIENCE;
    let mut last = PrinterState::Unknown("nothing was reported".to_owned());
    while Instant::now() < deadline {
        let status = client.status(print_id).expect("a status read is answered");
        if let Some(printer) = status.printer {
            last = printer.connection.clone();
            if wanted.contains(&printer.connection) {
                return printer.connection;
            }
        }
        std::thread::sleep(Duration::from_millis(500));
    }
    panic!("the machine reported {last:?} and this step needs one of {wanted:?}");
}

/// The nine steps, in the one order a real machine admits.
#[test]
fn the_same_nine_steps_are_answered_against_a_real_octoprint() {
    let root = tempfile::tempdir().expect("this journey's own root");
    let mut standing = supervisor::standing(root.path(), false);
    let world = standing.at.clone();
    let client = Client::new(&world.server, Actor::Operator).with_credential(&*world.credential);

    // journey step 1: status
    reads_status(&client, &world);
    // journey step 2: context
    reads_context_and_image(&client, &world);
    // journey step 3: manifest
    let wanted = the_manifest_the_print_runs_under(&client, &world);
    // journey step 4: start
    let opened_by_the_alert = world.print_id.clone();
    let world = starts(&client, &world, &wanted);
    // journey step 5: adjustment
    the_accepted_adjustment(&client, &world);
    // journey step 6: refusal
    the_refused_adjustment(&client, &world);
    // journey step 7: unreasoned
    unreasoned(&world);
    // journey step 8: history
    accounting(&client, &world, &opened_by_the_alert);
    // journey step 9: cancel
    cancels(&client, &world, &wanted);

    standing.stop();
}

/// Step one: read the status of the print this world opened.
fn reads_status(client: &Client, world: &supervisor::Supervisor) {
    let status = client
        .status(&world.print_id)
        .expect("a status read is answered");
    assert_eq!(status.print.id, world.print_id);
}

/// Step two: read context, and materialize its latest image.
fn reads_context_and_image(client: &Client, world: &supervisor::Supervisor) {
    let context = client
        .context(&world.print_id)
        .expect("a context read is answered");
    assert_eq!(context.context.print.id, world.print_id);

    let image = client
        .image(&world.image_id)
        .expect("an image read is answered");
    let path = image.path.as_ref().expect("the image is on this host");
    assert!(
        std::path::Path::new(path).is_absolute(),
        "an image is an absolute path on the server's own host: {path}"
    );
    assert_eq!(
        digest(std::path::Path::new(path)),
        image.record.sha256,
        "the file at the answered path is not the image the record declares"
    );
}

/// Step three: write a job manifest and read it back, before anything starts.
fn the_manifest_the_print_runs_under(
    client: &Client,
    world: &supervisor::Supervisor,
) -> JobManifest {
    let wanted = manifest(&world.file_name);
    let written = client
        .manifest_set(&world.print_id, REASON, &wanted)
        .expect("a manifest write is answered");
    assert_eq!(written.manifest.as_ref(), Some(&wanted));
    let read_back = client
        .manifest_get(&world.print_id)
        .expect("a manifest read is answered");
    assert_eq!(read_back.manifest, written.manifest);
    wanted
}

/// Step four: start a print, under the manifest step three wrote, and answer
/// the world about the print the start opened — which every step after it acts
/// on, since the cancel that made room for it ended the one the bring-up left.
fn starts(
    client: &Client,
    world: &supervisor::Supervisor,
    wanted: &JobManifest,
) -> supervisor::Supervisor {
    // The bring-up left a print running, and a machine already printing cannot
    // be started. Setting it down is this walk's own setup rather than one of
    // the nine steps, and the walk starts one again at the end.
    let _ = client.cancel(&world.print_id, "making room for the step that starts one");
    until(client, &world.print_id, &[PrinterState::Operational]);

    let started = client
        .start_print(&world.print_id, &world.file_name, wanted, REASON, None)
        .expect("a start is answered");
    assert!(matches!(started.record.decision, PolicyDecision::Accepted));
    let world = supervisor::Supervisor {
        print_id: started.record.print_id.clone(),
        ..world.clone()
    };
    until(client, &world.print_id, &[PrinterState::Printing]);
    assert_eq!(
        client
            .manifest_get(&world.print_id)
            .expect("a manifest read is answered")
            .manifest
            .as_ref(),
        Some(wanted),
        "the print the start opened does not run under the manifest it was given"
    );
    world
}

/// Step five: one adjustment inside the effective bounds, carrying a reason
/// and a duration.
fn the_accepted_adjustment(client: &Client, world: &supervisor::Supervisor) {
    let adjusted = client
        .set_feedrate_factor(&world.print_id, INSIDE, REASON, Some(DURATION))
        .expect("an adjustment inside the bounds is answered");
    assert!(matches!(adjusted.record.decision, PolicyDecision::Accepted));
    let intervention = adjusted
        .intervention
        .as_ref()
        .expect("a bounded adjustment opens an intervention");
    assert!(
        (intervention.applied_value - INSIDE).abs() < f64::EPSILON,
        "the intervention applied {} rather than {INSIDE}",
        intervention.applied_value
    );
}

/// Step six: one adjustment outside them, and the typed rejection it is
/// refused by.
fn the_refused_adjustment(client: &Client, world: &supervisor::Supervisor) {
    let refused = client
        .set_feedrate_factor(&world.print_id, OUTSIDE, REASON, None)
        .expect_err("an adjustment outside the bounds is refused");
    let ClientError::Rejected(rejection) = refused else {
        panic!("the policy's refusal did not arrive as one: {refused}");
    };
    assert!(matches!(
        rejection.reason,
        RejectionReason::OutOfBounds { .. }
    ));
    assert_eq!(rejection.requested, Some(OUTSIDE));
    let allowed = rejection
        .allowed
        .as_ref()
        .expect("the range that is allowed");
    assert!(
        allowed.max < OUTSIDE,
        "what is allowed does not admit what was asked for: {allowed:?}"
    );
}

/// Step seven: a mutating call whose reason is empty reaches no server at all.
///
/// Unchanged history proves nothing on its own — a request the server took and
/// recorded nowhere would leave it unchanged too — and neither does a client
/// pointed at an address nothing listens on, which reaches no server whatever
/// it is asked. So the calls are made through a **recording proxy in front of
/// the real supervisor**, and what is asserted is that not one of them went
/// through it.
fn unreasoned(world: &supervisor::Supervisor) {
    let proxy = live::Proxy::in_front_of(&world.server);
    let watched = Client::new(proxy.url(), Actor::Operator).with_credential(&*world.credential);

    // A read first: "nothing went through" is a claim about the calls below,
    // and against a proxy nothing could reach it would be true of everything.
    let before = watched
        .history(&world.print_id, None)
        .expect("a history read is answered");
    live::same("history", &before, &proxy.last().answer);
    let reads = proxy.calls();
    assert_eq!(reads, 1, "the read this step stands on went somewhere else");

    for empty in ["", "   "] {
        let refused = watched
            .pause(&world.print_id, empty)
            .expect_err("a call with no reason is refused");
        assert!(matches!(refused, ClientError::NoReason));
    }
    assert_eq!(
        proxy.calls(),
        reads,
        "a call with no reason reached the supervisor"
    );

    // Pointed at an address nothing is listening on, the same call still
    // refuses for want of a reason rather than for want of a server — which is
    // the client's own half of the same claim.
    let nowhere = Client::new("127.0.0.1:1", Actor::Operator);
    assert!(matches!(
        nowhere
            .pause(&world.print_id, "")
            .expect_err("a call with no reason is refused"),
        ClientError::NoReason
    ));

    let after = watched
        .history(&world.print_id, None)
        .expect("a history read is answered");
    assert_eq!(
        after.events.len(),
        before.events.len(),
        "a call with no reason left something in the history"
    );
    assert_eq!(
        proxy.calls(),
        reads + 1,
        "the read that closes this step went somewhere else"
    );
}

/// One event's payload as the type its kind declares, through the kind table,
/// or nothing for an event of another kind.
///
/// # Panics
///
/// Panics when an event under the kind is not of the type, which is a server
/// and a client generated from two different contracts.
fn read_as<P: printobserver_sdk::EventPayloadKind>(event: &EventRecord) -> Option<P> {
    event
        .payload_as::<P>()
        .map(|read| read.expect("a payload under its own kind is of its own type"))
}

/// Step eight: read history, and find the accepted action, its decision and
/// its outcome.
///
/// The alert the bring-up delivered is in the history of the print it opened,
/// which the cancel before step four ended: the start opened a print of its own.
fn accounting(client: &Client, world: &supervisor::Supervisor, opened_by_the_alert: &str) {
    let history = client
        .history(&world.print_id, Some(200))
        .expect("a history read is answered");
    let asked = history
        .events
        .iter()
        .filter_map(read_as::<ActionRequestedPayload>)
        .find(|payload| {
            matches!(
                payload.action,
                PrintAction::SetFeedrateFactor { factor, .. }
                    if (factor - INSIDE).abs() < f64::EPSILON
            )
        })
        .expect("the accepted adjustment is in the history");

    // Its decision: it is in the history as requested and not as rejected.
    assert!(
        !history
            .events
            .iter()
            .filter_map(read_as::<ActionRejectedPayload>)
            .any(|payload| payload.action_id == asked.action_id),
        "the accepted adjustment is in the history as a rejected one"
    );
    // And its outcome: it reached the machine.
    assert!(
        history
            .events
            .iter()
            .filter_map(read_as::<ActionExecutedPayload>)
            .any(|payload| payload.action_id == asked.action_id),
        "the history accounts for the accepted adjustment reaching no machine"
    );
    assert!(
        history
            .events
            .iter()
            .filter_map(read_as::<ActionRejectedPayload>)
            .any(|payload| matches!(payload.decision, PolicyDecision::Rejected(_))),
        "the history accounts for no refused action"
    );
    // And the alert the walk's first print was opened by: a history that had
    // lost the event a print exists because of would be one nobody could read
    // back.
    let first = client
        .history(opened_by_the_alert, Some(200))
        .expect("a history read is answered");
    assert!(
        first.events.iter().any(|event| event.id == world.event_id),
        "the history does not account for the event the first print was opened by"
    );
}

/// Step nine: cancel the print, last — and then put the hold print back, which
/// is teardown rather than a tenth step.
fn cancels(client: &Client, world: &supervisor::Supervisor, wanted: &JobManifest) {
    let cancelled = client
        .cancel(&world.print_id, REASON)
        .expect("a cancel is answered");
    assert!(matches!(
        cancelled.record.decision,
        PolicyDecision::Accepted
    ));
    assert!(matches!(
        cancelled.record.outcome,
        Some(ExecutionOutcome::Succeeded) | None
    ));

    until(client, &world.print_id, &[PrinterState::Operational]);
    let _ = client.start_print(
        &world.print_id,
        &world.file_name,
        wanted,
        "putting the hold print back where the bring-up left it",
        None,
    );
}

/// The digest of one file's bytes, in lowercase hexadecimal.
fn digest(path: &std::path::Path) -> String {
    use std::fmt::Write as _;

    use sha2::Digest as _;

    let mut hasher = sha2::Sha256::new();
    hasher.update(std::fs::read(path).expect("the image at the answered path opens"));
    hasher
        .finalize()
        .iter()
        .fold(String::new(), |mut said, byte| {
            let _ = write!(said, "{byte:02x}");
            said
        })
}

/// A real supervisor refuses a client that does not present its credential.
///
/// The refusal arrives as the error every other unsuccessful answer does, under
/// status `401`, saying what to present and quoting no credential — and the
/// same read presenting the credential in force is served.
///
/// It sits after the nine steps and every step's own code rather than before
/// them: the journey check's own suite removes step one by rewriting the file's
/// first status read, and a read here placed earlier would take that rewrite.
#[test]
fn a_client_without_the_credential_in_force_is_refused_by_a_real_supervisor() {
    let root = tempfile::tempdir().expect("this journey's own root");
    let mut standing = supervisor::standing(root.path(), false);
    let world = standing.at.clone();

    for (what, client) in [
        ("no credential", Client::new(&world.server, Actor::Operator)),
        (
            "a wrong credential",
            Client::new(&world.server, Actor::Operator)
                .with_credential("not-the-credential-in-force"),
        ),
    ] {
        match client.status(&world.print_id) {
            Err(ClientError::Refused { status, detail }) => {
                assert_eq!(status, 401, "a client with {what} was refused as {detail}");
                assert!(
                    detail.contains("Authorization: Bearer") && !detail.contains(&world.credential),
                    "a client with {what} was refused saying the wrong thing: {detail}"
                );
            }
            other => panic!("a client with {what} was not refused as unauthenticated: {other:?}"),
        }
    }
    let presenting =
        Client::new(&world.server, Actor::Operator).with_credential(&*world.credential);
    assert!(
        presenting.status(&world.print_id).is_ok(),
        "a client presenting the credential in force was not served"
    );
    standing.stop();
}

/// An events read's `print_ended` is answered the three ways the supervisor
/// sends it: absent from a read naming no print, and null from one naming the
/// print still running — each re-serialized as it was sent.
///
/// Through a recording proxy, so what the client answered is held to the body
/// the real supervisor sent rather than to what a client would think it sent.
#[test]
fn an_events_read_answers_print_ended_absent_or_null_as_the_supervisor_sent_it() {
    let root = tempfile::tempdir().expect("this journey's own root");
    let mut standing = supervisor::standing(root.path(), false);
    let world = standing.at.clone();
    let proxy = live::Proxy::in_front_of(&world.server);
    let client = Client::new(proxy.url(), Actor::Operator).with_credential(&*world.credential);

    let unscoped = client
        .events(None, None, None, None, Some(0), Some(20))
        .expect("an events read naming no print is answered");
    let sent: serde_json::Value =
        serde_json::from_str(&proxy.last().answer).expect("the supervisor sent a document");
    assert!(
        sent.get("print_ended").is_none(),
        "the supervisor sent a print_ended to a read naming no print: {sent}"
    );
    assert_eq!(
        unscoped.print_ended, None,
        "an absent print_ended was not read as absent"
    );
    live::same("events", &unscoped, &proxy.last().answer);

    let scoped = client
        .events(
            None,
            Some(world.print_id.clone()),
            None,
            None,
            Some(0),
            Some(20),
        )
        .expect("an events read naming the running print is answered");
    let sent: serde_json::Value =
        serde_json::from_str(&proxy.last().answer).expect("the supervisor sent a document");
    assert_eq!(
        sent.get("print_ended"),
        Some(&serde_json::Value::Null),
        "the supervisor did not send a running print's print_ended as null: {sent}"
    );
    assert_eq!(
        scoped.print_ended,
        Some(None),
        "a null print_ended was not read as null"
    );
    live::same("events", &scoped, &proxy.last().answer);
    standing.stop();
}