Files
qbit-printing/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceControlsTest.java
T
james.maesandClaude Fable 5.1 3fa78decc8
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
Shared Java Maven CI / secrets (push) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m6s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m16s
Shared Java Maven CI / verify (push) Successful in 3m31s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Shared Java Maven CI / gate (push) Successful in 9s
qrun-release / release (push) Successful in 3m41s
Shared QRun source version / version (push) Successful in 14s
qrun-release / postpublish (push) Successful in 14s
feat(realtime): printer approval over the socket (extension 2.4.0, product 1.1.0)
Add the printer-approval-v1 capability and the server-to-client PRINTERS frame
so a connector can show per-printer approval for the server it is connected to.

- HELLO accepts up to five capabilities including printer-approval-v1; the HTTP
  control poll accepts up to four and stores it in control_capabilities like the
  others; the ControlPollRequest schema enumeration is extended.
- After an accepted INVENTORY's ACK, a connection that advertised the capability
  receives PRINTERS: { "printers": [ { "printerId", "enabled" } ] } covering every
  printer in the device's latest accepted inventory (empty inventory: []).
- Every approval change (admin route, native approve/disable/set-approval
  processes, PrintingService.enablePrinter) already publishes the commit wakeup;
  the connection's single serialized writer now compares the current approval
  state with the last PRINTERS frame it sent and resends on change only.
  Connections without the capability never receive PRINTERS.
- PrintingService.printerApprovals(deviceId) exposes the list to host code.
- Advertise native extension 2.4.0; regenerate the OpenAPI fragment; bump the
  product to 1.1.0 (pairs with protocol 1.1.0 and Windows connector 1.2.0; wire
  payload v1 and subprotocol unchanged). CHANGELOG, README, REALTIME/OPERATIONS
  docs and a dated in-progress session-state note.
- Tests: five-capability HELLO, ACK-then-PRINTERS ordering with new printers
  disabled, approval via the admin route and the service pushing fresh frames,
  no PRINTERS on an unrelated job wakeup, empty inventory, a four-capability
  connection never receiving PRINTERS, and control-poll acceptance/bounds.

Verified: .\mvnw.cmd -Pui-demo verify -> 195 tests, 0 failures, 0 errors,
1 skipped (opt-in workload), 0 Checkstyle violations, BUILD SUCCESS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:45:16 -06:00

353 lines
19 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import org.h2.jdbcx.JdbcConnectionPool;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Real durable controls, replay boundaries, tenant isolation and competing dispatch. */
class PrintingDeviceControlsTest
{
/** Regression: pauseDisconnectAndRevokeHaveDistinctDurableSemantics. */
@Test
void pauseDisconnectAndRevokeHaveDistinctDurableSemantics() throws Exception
{
Fixture f = new Fixture();
String job = f.submit("first");
JSONObject pause = action("PAUSE_DISPATCH");
JSONObject receipt = f.service.deviceAction(f.device, pause, "admin");
assertTrue(f.service.claim(f.device).isNull("job"));
assertEquals(receipt.toString(), f.service.deviceAction(f.device, pause, "admin").toString());
f.act("RESUME_DISPATCH");
f.act("DISCONNECT");
assertTrue(f.service.claim(f.device).isNull("job"));
JSONObject state = f.service.deviceControlPoll(f.device, poll()).getJSONObject("desiredState");
assertEquals("DISCONNECTED", state.getString("connectionState"));
assertFalse(state.getBoolean("dispatchPaused"));
f.act("RECONNECT");
assertEquals(job, f.service.claim(f.device).getJSONObject("job").getString("jobId"));
f.act("DISCONNECT");
f.service.event(f.device, job, new JSONObject().put("eventId", "received").put("status", "RECEIVED"));
assertEquals("RECEIVED", f.service.getJob(job).getString("status"));
f.act("REVOKE");
assertEquals(401, assertThrows(PrintingException.class, () -> f.service.authenticate(f.token)).getStatus());
assertThrows(PrintingException.class, () -> f.service.claim(f.device));
assertThrows(PrintingException.class, () -> f.service.deviceControlPoll(f.device, poll()));
assertEquals(409, assertThrows(PrintingException.class, () -> f.act("RESUME_DISPATCH")).getStatus());
assertEquals("RECEIVED", f.service.getJob(job).getString("status"));
assertFalse(f.service.deviceCommandsPage(f.device, 50, null).getJSONArray("commands").isEmpty());
}
/** Regression: clearQueueReplayNeverCancelsNewJobsOrInflightWork. */
@Test
void clearQueueReplayNeverCancelsNewJobsOrInflightWork() throws Exception
{
Fixture f = new Fixture();
String active = f.submit("active");
f.service.claim(f.device);
f.service.event(f.device, active, new JSONObject().put("eventId", "submitting").put("status", "SUBMITTING"));
String queued = f.submit("queued");
JSONObject request = action("CLEAR_QUEUE");
JSONObject first = f.service.deviceAction(f.device, request, "admin");
assertEquals(1, first.getInt("cancelledJobCount"));
assertEquals("SUBMITTING", f.service.getJob(active).getString("status"));
assertEquals("FAILED", f.service.getJob(queued).getString("status"));
try(Connection c = f.source.getConnection())
{
assertEquals(2, PrintingService.query(c, "SELECT id FROM printing_job WHERE tenant_id='tenant' AND content_available=1").size());
assertEquals(12, PrintingService.query(c, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant' AND device_id='' ").getFirst().getLong("retained_bytes"));
assertEquals(0, PrintingService.query(c, "SELECT id FROM printing_document_orphan WHERE tenant_id='tenant'").size());
}
String later = f.submit("later");
assertEquals(first.toString(), f.service.deviceAction(f.device, request, "admin").toString());
assertEquals("QUEUED", f.service.getJob(later).getString("status"));
assertEquals(409, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, new JSONObject(request.toString()).put("action", "PAUSE_DISPATCH"), "admin")).getStatus());
}
/** Regression: remoteCommandsRequireCapabilitiesAndReceiptsAreImmutable. */
@Test
void remoteCommandsRequireCapabilitiesAndReceiptsAreImmutable() throws Exception
{
Fixture f = new Fixture();
assertEquals(409, assertThrows(PrintingException.class, () -> f.act("REFRESH_INVENTORY")).getStatus());
f.service.deviceControlPoll(f.device, poll());
JSONObject command = f.act("REFRESH_INVENTORY");
assertEquals("PENDING", command.getString("status"));
f.act("DISCONNECT");
assertTrue(f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").isEmpty());
f.act("RECONNECT");
assertEquals(command.getString("commandId"), f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").getJSONObject(0).getString("commandId"));
JSONObject receipt = receipt("INVENTORY_REFRESHED");
f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt);
f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt);
assertEquals(409, assertThrows(PrintingException.class, () -> f.service.deviceControlReceipt(f.device, command.getString("commandId"), new JSONObject(receipt.toString()).put("outcome", "FAILED"))).getStatus());
assertEquals("SUCCEEDED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status"));
assertTrue(f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").isEmpty());
}
/** Regression: cancelSpoolRequiresOwnedReceiptAndNeverReclassifiesJob. */
@Test
void cancelSpoolRequiresOwnedReceiptAndNeverReclassifiesJob() throws Exception
{
Fixture f = new Fixture();
f.service.deviceControlPoll(f.device, poll());
String job = f.submit("spool");
assertEquals(409, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, action("CANCEL_SPOOL").put("jobId", job), "admin")).getStatus());
f.service.claim(f.device);
f.service.event(f.device, job, new JSONObject().put("eventId", "spooled").put("status", "SPOOLED").put("spoolerJobId", "123"));
JSONObject command = f.service.deviceAction(f.device, action("CANCEL_SPOOL").put("jobId", job), "admin");
f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt("OWNERSHIP_MISMATCH"));
assertEquals("FAILED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status"));
assertEquals("SPOOLED", f.service.getJob(job).getString("status"));
assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, action("CANCEL_SPOOL").put("jobId", job).put("spoolerJobId", "999"), "admin")).getStatus());
}
/** Regression: tenantDeviceCursorAndReceiptBoundariesAreEnforced. */
@Test
void tenantDeviceCursorAndReceiptBoundariesAreEnforced() throws Exception
{
Fixture f = new Fixture();
String second = f.enroll().getString("deviceId");
JSONObject first = f.act("PAUSE_DISPATCH");
String id = first.getString("commandId");
assertEquals(404, assertThrows(PrintingException.class, () -> f.service.getDeviceCommand(second, id)).getStatus());
assertEquals(404, assertThrows(PrintingException.class, () -> f.service.deviceCommandsPage(second, 1, id)).getStatus());
assertEquals(404, assertThrows(PrintingException.class, () -> f.service.deviceControlReceipt(second, id, receipt("FAILED"))).getStatus());
PrintingService other = new PrintingService(f.source, "other-tenant");
assertEquals(404, assertThrows(PrintingException.class, () -> other.deviceAction(f.device, action("REVOKE"), "other-admin")).getStatus());
f.act("RESUME_DISPATCH");
JSONObject page = f.service.deviceCommandsPage(f.device, 1, null);
assertEquals(1, page.getJSONArray("commands").length());
assertEquals(id, f.service.deviceCommandsPage(f.device, 1, page.getString("nextCursor")).getJSONArray("commands").getJSONObject(0).getString("commandId"));
}
/** Regression: expiredCommandsAcceptLateActualOutcomeAndStaleActionsNeverExecute. */
@Test
void expiredCommandsAcceptLateActualOutcomeAndStaleActionsNeverExecute() throws Exception
{
Fixture f = new Fixture();
f.service.deviceControlPoll(f.device, poll());
JSONObject command = f.act("REFRESH_INVENTORY");
try(Connection c = f.source.getConnection())
{
PrintingService.update(c, "UPDATE printing_device_command SET expires_at=? WHERE id=?", Instant.now().minusSeconds(60).toString(), command.getString("commandId"));
}
assertTrue(f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").isEmpty());
assertEquals("EXPIRED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status"));
f.service.deviceControlReceipt(f.device, command.getString("commandId"), receipt("INVENTORY_REFRESHED"));
assertEquals("SUCCEEDED", f.service.getDeviceCommand(f.device, command.getString("commandId")).getString("status"));
JSONObject old = action("CLEAR_QUEUE").put("issuedAt", Instant.now().minusSeconds(901).toString());
assertEquals(410, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, old, "admin")).getStatus());
JSONObject saved = action("PAUSE_DISPATCH");
f.service.deviceAction(f.device, saved, "admin");
saved.put("issuedAt", Instant.now().minusSeconds(8 * 86400).toString());
try(Connection c = f.source.getConnection())
{
PrintingService.update(c, "UPDATE printing_device_command SET created_at=?,expires_at=?,request_json=? WHERE request_id=?", saved.getString("issuedAt"), Instant.parse(saved.getString("issuedAt")).plusSeconds(900).toString(), saved.toString(), saved.getString("requestId"));
}
assertEquals("SUCCEEDED", f.service.deviceAction(f.device, saved, "admin").getString("status"));
f.act("RESUME_DISPATCH");
assertEquals(410, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, saved, "admin")).getStatus());
assertFalse(f.service.deviceControlPoll(f.device, poll()).getJSONObject("desiredState").getBoolean("dispatchPaused"));
assertEquals(400, assertThrows(PrintingException.class, () -> f.service.deviceAction(f.device, action("PAUSE_DISPATCH").put("issuedAt", Instant.now().plusSeconds(120).toString()), "admin")).getStatus());
}
/** Regression: concurrentClearAndClaimCannotBothOwnTheSameQueuedJob. */
@Test
void concurrentClearAndClaimCannotBothOwnTheSameQueuedJob() throws Exception
{
Fixture f = new Fixture();
PrintingService second = new PrintingService(f.source, "tenant");
String job = f.submit("race");
CountDownLatch start = new CountDownLatch(1);
try(var executor = Executors.newFixedThreadPool(2))
{
var clear = executor.submit(() ->
{
start.await();
return f.act("CLEAR_QUEUE");
});
var claim = executor.submit(() ->
{
start.await();
return second.claim(f.device);
});
start.countDown();
int canceled = clear.get().getInt("cancelledJobCount");
boolean claimed = !claim.get().isNull("job");
assertEquals(canceled == 0, claimed);
assertEquals(claimed ? "CLAIMED" : "FAILED", f.service.getJob(job).getString("status"));
}
}
/** Regression: pendingAndHistoryBudgetsRetainEmergencyRevocation. */
@Test
void pendingAndHistoryBudgetsRetainEmergencyRevocation() throws Exception
{
Fixture f = new Fixture();
f.service.deviceControlPoll(f.device, poll());
for(int i = 0; i < 100; i++)
{
f.act("REFRESH_INVENTORY");
}
assertEquals(429, assertThrows(PrintingException.class, () -> f.act("REFRESH_INVENTORY")).getStatus());
assertEquals(20, f.service.deviceControlPoll(f.device, poll()).getJSONArray("commands").length());
for(int i = 100; i < 1000; i++)
{
f.act("PAUSE_DISPATCH");
}
assertEquals(429, assertThrows(PrintingException.class, () -> f.act("RESUME_DISPATCH")).getStatus());
assertEquals("CREDENTIAL_REVOKED", f.act("REVOKE").getString("resultCode"));
assertEquals(401, assertThrows(PrintingException.class, () -> f.service.authenticate(f.token)).getStatus());
}
/** All control paths work with a single real pooled connection and preserve escaped receipt data. */
@Test
void singleConnectionPreservesStateAndBoundedUnicodeReceipts() throws Exception
{
JdbcConnectionPool pool = JdbcConnectionPool.create("jdbc:h2:mem:controls-pool-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
pool.setMaxConnections(1);
pool.setLoginTimeout(2);
try
{
PrintingService first = new PrintingService(pool, "tenant");
first.initialize();
String device = first.enroll(first.createEnrollment("facility", "device").put("deviceName", "device").put("agentVersion", "test")).getString("deviceId");
first.deviceControlPoll(device, poll());
JSONObject command = first.deviceAction(device, action("REFRESH_INVENTORY"), "actor");
JSONObject result = receipt("INVENTORY_REFRESHED").put("message", "\u0001".repeat(1024));
first.deviceControlReceipt(device, command.getString("commandId"), result);
PrintingService reopened = new PrintingService(pool, "tenant");
assertTrue(reopened.getDeviceCommand(device, command.getString("commandId")).getJSONObject("receipt").similar(result));
JSONObject paused = action("PAUSE_DISPATCH");
first.deviceAction(device, paused, "actor");
assertTrue(reopened.deviceControlPoll(device, poll()).getJSONObject("desiredState").getBoolean("dispatchPaused"));
assertEquals(2, reopened.deviceCommandsPage(device, 10, null).getJSONArray("commands").length());
assertEquals("SUCCEEDED", reopened.deviceAction(device, paused, "actor").getString("status"));
}
finally
{
pool.dispose();
}
}
/** 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)
{
return new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString()).put("action", action);
}
/** Advertise only the two implemented remote operations. */
private static JSONObject poll()
{
return new JSONObject().put("controlVersion", 1).put("agentVersion", "test").put("lastAppliedRevision", 0)
.put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY", "CANCEL_SPOOL")));
}
/** A synthetic command observation carries no arbitrary executable data. */
private static JSONObject receipt(String outcome)
{
return new JSONObject().put("receiptId", UUID.randomUUID().toString()).put("outcome", outcome).put("completedAt", Instant.now().toString());
}
/** Real isolated database and approved synthetic printer for each test. */
private static final class Fixture
{
private final DriverDataSource source = new DriverDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
private final PrintingService service = new PrintingService(source, "tenant");
private final String device;
private final String token;
/** Initialize once; new service instances must observe the same durable state. */
private Fixture() throws Exception
{
service.initialize();
JSONObject enrolled = enroll();
device = enrolled.getString("deviceId");
token = enrolled.getString("deviceToken");
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "zebra").put("name", "Zebra").put("formats", new JSONArray(List.of("ZPL")))));
service.enablePrinter(device, "zebra", true);
}
/** Pair without sharing another device's credential. */
private JSONObject enroll() throws Exception
{
return service.enroll(service.createEnrollment("warehouse", "station").put("deviceName", "station").put("agentVersion", "test"));
}
/** Use normal admission and retained document accounting. */
private String submit(String key) throws Exception
{
return service.submit(new JSONObject().put("deviceId", device).put("printerId", "zebra").put("title", "Synthetic")
.put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", key)).getString("jobId");
}
/** Apply through the same facade used by authenticated native and HTTP adapters. */
private JSONObject act(String action) throws Exception
{
return service.deviceAction(device, action(action), "admin");
}
}
}