feat(realtime): printer approval over the socket (extension 2.4.0, product 1.1.0) #25

Merged
james.maes merged 1 commits from feat/printer-approval-20261105 into main 2026-11-05 22:22:19 +00:00
15 changed files with 236 additions and 24 deletions

No files matched your search

+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 1.1.0
Native printing extension **2.4.0** (additive; wire payload v1 and the agent
subprotocol `qqq-printing.v1` are unchanged). Pairs with protocol 1.1.0 and Windows
connector 1.2.0; 1.0.x connectors keep working unchanged.
- New HELLO and HTTP control-poll capability `printer-approval-v1` (HELLO accepts up to
five capabilities, the control poll up to four; it is stored in the device's control
capabilities like the others).
- New server-to-client realtime frame `PRINTERS`: `{ "printers": [ { "printerId", "enabled" } ] }`,
the complete approval state of every printer in the device's latest accepted inventory.
Sent only to connections that advertised the capability, after each accepted INVENTORY's
ACK and whenever a printer's approval changes through any path (admin route, native
approve/disable/set-approval processes, `PrintingService.enablePrinter`). An empty
inventory yields an empty list; other connections never receive the frame.
- `PrintingService.printerApprovals(deviceId)` exposes the same list to host code.
- Regenerated OpenAPI fragment (`printing.yaml`): semantic version 2.4.0 and the extended
`ControlPollRequest` capability enumeration. No authorization, queue, replay or document
behavior changed.
## 1.0.0
Product version 1.0.0 includes record, table-list and text print processes,
+3 -3
View File
@@ -1,10 +1,10 @@
# QQQ Printing
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.0.1 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.0.0. The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.0.0** supports native printing **2.3**, wire payload v1, real-time jobs and controls, and native browser sign-in through QQQ. Upgrade the server before the matching Windows connector.
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.2.0 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.1.0 (1.0.1 / 1.0.0 remain compatible). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.1.0** supports native printing **2.4**, wire payload v1, real-time jobs and controls, native browser sign-in through QQQ, and pushes each exposed printer's approval to connectors that advertise `printer-approval-v1`. Upgrade the server before the matching Windows connector.
**Released:** [QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), with a library JAR, matching POM and `SHA256SUMS`. The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.0.0, native extension 2.3.0 and wire payload v1 are separate version contracts.
**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.1.0, native extension 2.4.0 and wire payload v1 are separate version contracts.
Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target.
@@ -127,7 +127,7 @@ On Printers, select one or more rows or open a printer record to **Approve print
Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build.
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. A connector whose HELLO advertises `printer-approval-v1` (extension 2.4.0, connector 1.2.0) receives a `PRINTERS` frame listing `{ printerId, enabled }` for every queue in its latest accepted inventory after each accepted inventory and whenever an approval changes through any path; older connectors never receive it and keep reading the operator catalog. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default decoded limits are 100 MiB for PDF and 20 MiB for ZPL, both configurable in the native Printing connection and limits process. Queue approval and format compatibility are checked before insertion.
+1 -1
View File
@@ -49,7 +49,7 @@ Inventory snapshots remain limited to 200 entries. Rotating IDs cannot bypass cu
## Device controls and durable action receipts
The native client API is the registered `printing` extension, semantic version
`2.3.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
`2.4.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
its generated OpenAPI fragment is `src/main/resources/openapi/qqq-v1/printing.yaml`.
The contract test checks this fragment against the registered endpoint specs and
the initial semantic compatibility baseline. Core QQQ session authentication and
+22 -5
View File
@@ -1,8 +1,8 @@
# Real-time printing host integration
[QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0)
implements native printing extension **2.3.0** (wire envelope v1), paired with Windows
connector **1.0.1**. Upgrade the server before the connector; a real-time connector
QQQ Printing 1.1.0 (the released library is [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0))
implements native printing extension **2.4.0** (wire envelope v1), paired with Windows
connector **1.2.0**; connector 1.0.1 remains compatible. Upgrade the server before the connector; a real-time connector
rejects a server that advertises only extension 1.x. There is no timed job/control
polling fallback.
@@ -108,5 +108,22 @@ this is a breaking behavior change even though request bodies remain wire v1.
The QQQ framework API `/qqq/v1`, extension path `/qbit/printing` and WebSocket
subprotocol `qqq-printing.v1` remain unchanged. Upgrade server and client together;
an incompatible older client must report upgrade-required instead of using polling fallback.
Current extension 2.3.0 retains that requirement and adds operator/media support.
The product/library version is independently 1.0.0.
Extension 2.3.0 retained that requirement and added operator/media support; 2.4.0
adds printer approval over the socket (below). The product/library version is
independently 1.1.0.
## Printer approval (extension 2.4.0)
A HELLO may carry up to five capabilities; the fifth, `printer-approval-v1`, is
also accepted and stored by the HTTP control poll. Only on a connection that
advertised it, the server sends a `PRINTERS` frame
(`{ "printers": [ { "printerId", "enabled" } ] }`) with the complete approval
state of every printer in the device's latest accepted inventory: after the ACK
of each accepted INVENTORY, and whenever a printer's `enabled` changes for that
device through any path (the admin route, the native approve/disable/set-approval
processes or application code calling `enablePrinter`). Every approval path
publishes the existing commit wakeup; the connection's serialized writer compares
the current state with the last frame it sent and resends only on a change, so an
unrelated job wakeup does not repeat it. An empty inventory yields
`{ "printers": [] }`. The client does not acknowledge the frame; it carries no job
or document data. Connections without the capability never receive it.
+7 -1
View File
@@ -1,4 +1,10 @@
# Session state — 2026-11-04
# Session state — 2026-11-05
## In progress — 2026-11-05: printer approval over realtime (1.1.0 / extension 2.4.0)
Branch `feat/printer-approval-20261105` adds the `printer-approval-v1` capability and the `PRINTERS` frame
(see [CHANGELOG](../CHANGELOG.md) 1.1.0 and [real-time integration](REALTIME.md)). Product version is bumped to
1.1.0 in the POM; nothing is tagged, released or published, and the released state below is unchanged.
## Current — 1.0.0 released
+1 -1
View File
@@ -9,7 +9,7 @@
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>1.0.0</version>
<version>1.1.0</version>
<name>QQQ Printing</name>
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
<properties>
@@ -68,7 +68,7 @@ final class PrintingApiSchemas
add(schemas, "TestRequest", fields("requestId", id(), "printerId", id(), "format", enumeration("PDF", "ZPL"), "testProfile", text(40)), "requestId", "printerId", "format");
add(schemas, "RetryRequest", fields("requestId", id(), "confirmDuplicateRisk", bool()), "requestId");
add(schemas, "DesiredState", fields("revision", number(), "dispatchPaused", bool(), "connectionState", enumeration("CONNECTED", "DISCONNECTED")), "revision", "dispatchPaused", "connectionState");
add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision");
add(schemas, "ControlPollRequest", fields("controlVersion", number(), "agentVersion", text(100), "capabilities", array(enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1")), "lastAppliedRevision", number()), "controlVersion", "agentVersion", "capabilities", "lastAppliedRevision");
add(schemas, "Command", fields("commandId", id(), "requestId", id(), "action", enumeration("REFRESH_INVENTORY", "CANCEL_SPOOL"), "createdAt", time(), "expiresAt", time(), "jobId", id()), "commandId", "requestId", "action", "createdAt", "expiresAt");
add(schemas, "ControlPoll", fields("controlVersion", number(), "serverTime", time(), "pollIntervalSeconds", number(), "desiredState", ref("DesiredState"), "commands", array(ref("Command"))), "controlVersion", "serverTime", "pollIntervalSeconds", "desiredState", "commands");
add(schemas, "ReceiptRequest", fields("receiptId", id(), "outcome", enumeration("INVENTORY_REFRESHED", "CANCEL_REQUESTED", "ALREADY_ABSENT", "OWNERSHIP_MISMATCH", "UNSUPPORTED", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"), "completedAt", time(), "message", text(1024)), "receiptId", "outcome", "completedAt");
@@ -267,8 +267,8 @@ final class PrintingDeviceControls
}
String version = text(request, "agentVersion", 100);
long applied = integer(request, "lastAppliedRevision");
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 3
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
if(!(request.opt("capabilities") instanceof JSONArray capabilities) || capabilities.length() > 4
|| capabilities.toList().stream().anyMatch(value -> !REMOTE.contains(value) && !"printer-media-v1".equals(value) && !"printer-approval-v1".equals(value)) || Set.copyOf(capabilities.toList()).size() != capabilities.length())
{
throw new PrintingException(400, "Invalid control capabilities");
}
@@ -319,6 +319,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
private long appliedSettings = -1;
private long sentState = -1;
private long sentSettings = -1;
private @Nullable String sentPrinters;
/** Bind one mailbox to its authenticated socket. */
@@ -482,8 +483,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
if(hello != null || !payload.keySet().equals(Set.of("agentVersion", "capabilities", "lastAppliedRevision", "lastSettingsRevision", "limits"))
|| !payload.getJSONArray("capabilities").toList().contains("realtime-v1")
|| payload.getJSONArray("capabilities").length() > 4
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").containsAll(payload.getJSONArray("capabilities").toList()))
|| payload.getJSONArray("capabilities").length() > 5
|| !Set.of("realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1").containsAll(payload.getJSONArray("capabilities").toList()))
{
throw new PrintingException(409, "Realtime capability is required");
}
@@ -523,7 +524,13 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
changed();
}
case "HEARTBEAT" -> service.heartbeat(device, payload);
case "INVENTORY" -> service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device));
case "INVENTORY" ->
{
service.inventory(device, payload.getJSONArray("printers"), PrintingAgentAuthorization.inventory(device));
send("ACK", new JSONObject().put("acknowledgedMessageId", messageId));
printers(true, true);
return;
}
case "PING" ->
{
service.realtimeContact(device);
@@ -606,7 +613,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
JSONArray capabilities = new JSONArray();
for(Object value : hello.getJSONArray("capabilities"))
{
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1").contains(value))
if(Set.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1").contains(value))
{
capabilities.put(value);
}
@@ -660,6 +667,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
send("DESIRED_STATE", desired);
}
}
printers(!initial, false);
if(!ready(sentSettings))
{
return;
@@ -674,6 +682,34 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
}
/***************************************************************************
** Push the complete approval state of the device's current inventory to a
** connection that advertised printer-approval-v1. An accepted INVENTORY
** always sends it; a commit wakeup sends it only when approval changed since
** the last frame, and the initial snapshot only records the baseline so an
** unrelated wakeup never produces a frame. Other connections never receive it.
***************************************************************************/
private void printers(boolean push, boolean always) throws Exception
{
if(!helloPayload().getJSONArray("capabilities").toList().contains("printer-approval-v1"))
{
return;
}
JSONObject payload = new JSONObject().put("printers", service.printerApprovals(device));
String current = payload.toString();
boolean changed;
synchronized(this)
{
changed = always || (sentPrinters != null && !current.equals(sentPrinters));
sentPrinters = current;
}
if(push && changed)
{
send("PRINTERS", payload);
}
}
/** Revalidate authority and await one bounded asynchronous socket write. */
@@ -804,8 +804,32 @@ public class PrintingService
/***************************************************************************
** Approval of every printer in the device's latest accepted inventory, for
** the realtime PRINTERS frame: printerId and enabled per available queue,
** nothing else. A withdrawn queue is not listed even though its approval and
** history are retained.
***************************************************************************/
public JSONArray printerApprovals(String deviceId) throws SQLException
{
try(Connection connection = dataSource.getConnection())
{
requireDevice(connection, deviceId);
JSONArray result = new JSONArray();
for(JSONObject row : query(connection, "SELECT printer_id, enabled FROM printing_printer WHERE tenant_id=? AND device_id=? AND available=1 ORDER BY printer_id LIMIT 200", tenantId, deviceId))
{
result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("enabled", row.getInt("enabled") == 1));
}
return result;
}
}
/***************************************************************************
** Enable a queue explicitly; discovery alone never grants print access.
** Every approval path (admin route, native processes, application code)
** converges here, so the commit wakeup reaches the device's live connection.
***************************************************************************/
public void enablePrinter(String deviceId, String printerId, boolean enabled) throws SQLException
{
@@ -49,11 +49,11 @@ public final class PrintingUiApi
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade. */
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade; 2.4.0 adds the printer-approval-v1 capability and the realtime PRINTERS frame carrying each exposed printer's approval. */
public static MiddlewareSpecExtension extension()
{
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.3.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1.")
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.4.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1; printer-approval-v1 and the realtime PRINTERS approval frame.")
.withSpec(Session.class)
.withSpec(Bind.class)
.withSpec(Enroll.class)
@@ -208,6 +208,7 @@ components:
- "REFRESH_INVENTORY"
- "CANCEL_SPOOL"
- "printer-media-v1"
- "printer-approval-v1"
type: "string"
type: "array"
lastAppliedRevision:
@@ -752,9 +753,10 @@ info:
email: "contact@kingsrook.com"
description: "Native current-user printing with secondary device proof, device-control-v1\
\ and realtime-v1; record, table-list and text printing processes; render to PDF\
\ with the app header and footer; least-privilege operator catalog and printer-media-v1."
\ with the app header and footer; least-privilege operator catalog and printer-media-v1;\
\ printer-approval-v1 and the realtime PRINTERS approval frame."
title: "QQQ Middleware API extension: printing"
version: "2.3.0"
version: "2.4.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/printing/session:
@@ -274,6 +274,21 @@ class PrintingDeviceControlsTest
}
/** The HTTP control poll stores printer-approval-v1 like the other capabilities and still bounds the list. */
@Test
void pollAcceptsAndStoresPrinterApprovalCapability() throws Exception
{
Fixture f = new Fixture();
JSONArray capabilities = new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"));
f.service.deviceControlPoll(f.device, poll().put("capabilities", capabilities));
JSONObject control = f.service.devices().getJSONObject(0).getJSONObject("control");
assertEquals(capabilities.toString(), control.getJSONArray("capabilities").toString());
assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceControlPoll(f.device, poll().put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1", "REFRESH_INVENTORY"))))).getStatus());
assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceControlPoll(f.device, poll().put("capabilities", new JSONArray(List.of("printer-approval-v2"))))).getStatus());
assertEquals(capabilities.toString(), f.service.devices().getJSONObject(0).getJSONObject("control").getJSONArray("capabilities").toString());
}
/** Explicit fresh action identity, retained unchanged by callers when retrying. */
private static JSONObject action(String action)
{
@@ -106,6 +106,98 @@ public class PrintingRealtimeTest
}
/** PRINTERS follows each accepted inventory and every approval change, only on a connection that advertised printer-approval-v1. */
@Test
void printerApprovalIsPushedOnlyToCapableConnections() throws Exception
{
String jdbc = "jdbc:h2:file:" + directory.resolve("approval-realtime").toString().replace('\\', '/');
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", "synthetic-realtime-password-1234"));
AtomicReference<Javalin> app = new AtomicReference<>();
QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
{
host.configure(server, false);
server.withJavalinConfigurationCustomizer(app::set);
}));
PrintingService service = Objects.requireNonNull(launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(), "The printing service was not configured");
String base = "http://127.0.0.1:" + Objects.requireNonNull(app.get(), "Test HTTP server was not started").port();
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Approval fixture").put("deviceName", "Approval fixture").put("agentVersion", "test"), "operator");
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
JSONArray approval = new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-media-v1").put("printer-approval-v1");
JSONArray inventory = new JSONArray()
.put(new JSONObject().put("printerId", "laser").put("name", "Synthetic laser").put("status", "READY").put("formats", new JSONArray().put("PDF")))
.put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("status", "READY").put("formats", new JSONArray().put("ZPL")));
try(HttpClient http = HttpClient.newHttpClient())
{
String cookie = login(http, base);
try(PrintingSocketFixture capable = PrintingSocketFixture.open(http, base, cookie, device, proof))
{
capable.initialize(approval);
capable.take("JOB_AVAILABLE");
assertTrue(deviceSnapshot(service, device).getJSONObject("control").getJSONArray("capabilities").toList().contains("printer-approval-v1"));
capable.received.clear();
capable.send("INVENTORY", "approval-inventory", new JSONObject().put("printers", inventory));
JSONObject ack = Objects.requireNonNull(capable.received.poll(10, TimeUnit.SECONDS), "Expected ACK");
assertEquals("ACK", ack.getString("type"));
assertEquals("approval-inventory", ack.getJSONObject("payload").getString("acknowledgedMessageId"));
JSONObject printers = Objects.requireNonNull(capable.received.poll(10, TimeUnit.SECONDS), "Expected PRINTERS after ACK");
assertEquals("PRINTERS", printers.getString("type"));
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", false)).put(new JSONObject().put("printerId", "queue").put("enabled", false)).toString(),
printers.getJSONObject("payload").getJSONArray("printers").toString());
assertEquals(java.util.Set.of("printers"), printers.getJSONObject("payload").keySet());
HttpResponse<String> approved = http.send(HttpRequest.newBuilder(URI.create(base + PrintingRoutes.BASE + "/admin/devices/" + device + "/printers/laser"))
.header("Authorization", "Bearer synthetic-management-token-long-enough").header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(new JSONObject().put("enabled", true).toString())).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, approved.statusCode(), approved.body());
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", true)).put(new JSONObject().put("printerId", "queue").put("enabled", false)).toString(),
capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").toString());
service.enablePrinter(device, "queue", true);
assertTrue(capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").getJSONObject(1).getBoolean("enabled"));
capable.received.clear();
String job = service.submit(request(device, "approval-unrelated-wakeup")).getString("jobId");
capable.take("JOB_AVAILABLE");
assertFalse(drain(capable).stream().anyMatch(frame -> "PRINTERS".equals(frame.getString("type"))), "An unrelated commit wakeup must not resend PRINTERS");
service.cancel(device, job);
capable.received.clear();
capable.send("INVENTORY", "approval-empty-inventory", new JSONObject().put("printers", new JSONArray()));
capable.take("ACK");
assertEquals("[]", capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").toString());
}
try(PrintingSocketFixture legacy = PrintingSocketFixture.open(http, base, cookie, device, proof))
{
legacy.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-media-v1"));
legacy.take("JOB_AVAILABLE");
assertFalse(deviceSnapshot(service, device).getJSONObject("control").getJSONArray("capabilities").toList().contains("printer-approval-v1"));
legacy.received.clear();
legacy.send("INVENTORY", "legacy-inventory", new JSONObject().put("printers", inventory));
assertEquals("legacy-inventory", legacy.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId"));
service.enablePrinter(device, "laser", false);
assertFalse(drain(legacy).stream().anyMatch(frame -> "PRINTERS".equals(frame.getString("type"))), "A connection without printer-approval-v1 never receives PRINTERS");
assertEquals(1, service.printerApprovals(device).toList().stream().filter(value -> Boolean.TRUE.equals(((java.util.Map<?, ?>) value).get("enabled"))).count());
}
}
finally
{
launcher.stop();
}
}
/** Collect whatever the server pushes in a short bounded window; this is an absence check, not a polling loop. */
private static java.util.List<JSONObject> drain(PrintingSocketFixture socket) throws Exception
{
java.util.List<JSONObject> frames = new java.util.ArrayList<>();
JSONObject next = socket.received.poll(500, TimeUnit.MILLISECONDS);
while(next != null)
{
frames.add(next);
next = socket.received.poll(200, TimeUnit.MILLISECONDS);
}
return frames;
}
/**
* Closing the routes waits for a channel worker that is already running. A drain that outlived the host could query a stopped file
* database and recreate it empty, which previously left a test's temporary directory populated after cleanup.
@@ -61,7 +61,7 @@ class PrintingUiApiContractTest
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertEquals(20, model.getPaths().size());
assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/")));
assertEquals("2.3.0", extension.getSemanticVersion());
assertEquals("2.4.0", extension.getSemanticVersion());
assertTrue(generated.contains("X-QQQ-Device-Token"));
assertTrue(generated.contains("application/octet-stream"));
assertTrue(generated.contains("principalId"));