feat: synchronize print profiles and bound history queries
Co-authored-by: Codex (GPT-6) <noreply@openai.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
13 files changed
+463
-30
No files matched your search
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: QRun-IO/qqq-print-protocol
|
||||
ref: 633f220644a9b844b3ac4a548d12d8f5c9ebfaf1
|
||||
ref: a0de93b00e602abde9be77c1ec9103b670bf3c09
|
||||
path: qqq-print-protocol
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.2-rc.1 — 2026-10-30
|
||||
|
||||
- Synchronize client-managed device/printer display names, descriptions and locations; preserve physical computer/queue names and stable identifiers.
|
||||
- Add read-only native profile fields, longer names and additive schema migration/backfill.
|
||||
- Bound queue claims and history reads, avoid loading document payloads for metadata, and provide keyset-paged admin history.
|
||||
- Verify 10,000 synthetic jobs, long-text profiles, migration, explicit clearing and tenant isolation (22 tests, zero Checkstyle violations).
|
||||
|
||||
## 0.1.1-rc.1 — 2026-10-29
|
||||
|
||||
- Device-scoped test print, explicit reprint and pre-dispatch cancellation routes.
|
||||
|
||||
@@ -73,6 +73,12 @@ After the artifact is available in your configured repository, add:
|
||||
|
||||
Supply an existing JDBC DataSource/pool and a QQQ RDBMS backend pointing to that same database. Initial schema is created by `initialize()`; the implementation uses portable H2/PostgreSQL SQL, but only H2 has been tested so far.
|
||||
|
||||
Device and printer names, descriptions and locations are edited in the Windows client and synchronized through heartbeat/inventory. QQQ's native record forms and selectors show the effective friendly name; the physical computer/queue name remains visible in Details. These synchronized fields are read-only in QQQ so two editors do not overwrite each other. Approval remains a separate server control. Friendly names/locations allow 512 characters, descriptions 4096, and physical names/job titles 1024. Clearing a friendly name restores the technical name without changing IDs or routing.
|
||||
|
||||
`initialize()` adds profile columns, backfills physical names, widens existing name/title columns and creates dispatch/history indexes without deleting records. The existing `name` column becomes the effective display label; `computer_name`/`queue_name` retain technical names and `display_name` holds the optional override. Back up the database and run schema initialization during a maintenance/deployment window; column widening and index creation can lock populated tables.
|
||||
|
||||
Job history uses `GET /printing/v1/admin/jobs?limit=200&before=<nextCursor>` with opaque keyset cursors and a maximum 200 rows per page. Claims read one candidate, pending inspection reads 50, and all these queries project metadata without fetching stored documents. Native dashboard lists already cap attention rows at 10 per category and recent work at 25. Dispatch/history indexes support growing histories; the synthetic 10,000-row regression test checks bounded pages and claim correctness, **not** a certified 10,000-print/day throughput measurement. Driver speed, document size, database hardware and retention policy still determine capacity. Documents remain stored until the host supplies a retention policy; this change does not silently delete jobs or documents.
|
||||
|
||||
```java
|
||||
PrintingService printing = new PrintingService(dataSource, tenantId);
|
||||
printing.initialize();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Session state — current checkpoint 2026-10-29
|
||||
|
||||
2026-10-30 source0.1.2-rc.1: implemented client-authoritative Name/Description/Location synchronization, preserved computer/queue technical names, read-only native QQQ profile fields and friendly selector labels. Added additive schema backfill/widening, dispatch/history indexes, one-row payload-free claims and metadata-only keyset history pages. Added long-text, explicit-clear, migration, tenant and synthetic 10,000-row pagination/claim tests. Root verified22tests with zeroCheckstyle violations using mvnw.cmd verify. Protocol28definitions/21fixtures passed and CI now pins a0de93b. Prior verification below predates these changes. No physical prints were requested by this work.
|
||||
|
||||
Current source is prepared for **0.1.1-rc.1**. Latest Java verification passed **19 tests with zero Checkstyle violations**. The full audit follow-up implements device-scoped test-print/retry/cancel/queued-job routes and native **Print again** / **Cancel queued jobs** actions from job records and table selections. Native review uses named records, rechecks administrator/tenant/row access and current state, requires explicit duplicate-risk confirmation for uncertain/spooled output, and uses a stable request identity for retry replay. Retry creates a new job and preserves the original outcome. Queued cancellation is serialized against claim and cannot promise cancellation after dispatch.
|
||||
|
||||
The Windows companion now displays cloud-queued jobs before receipt, allowing server-validated cancellation while still queued. It also has independent heartbeat during slow native printing, bounded subprocess isolation/orphan cleanup, persistent history/review and retained deduplication evidence. The final Windows changed-tree gate passed **102 tests (36 Core + 66 Windows/CLI), zero build warnings/errors**, including the heartbeat and queued-cancellation regressions.
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@
|
||||
- [x] Separate local printer availability from server approval and block withdrawn queues.
|
||||
- [x] Prepare public repository documentation, license, CI and GitHub prerelease artifacts.
|
||||
- [x] Verify remote CI/security and publish v0.1.0-rc.1 JAR/POM/checksum assets.
|
||||
- [ ] Logical destinations, profile management and object-storage document backend.
|
||||
- [x] Client-owned device/printer name, description and location synchronized into native QQQ metadata.
|
||||
- [ ] Logical destinations and object-storage document backend.
|
||||
- [ ] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; qualify sustained throughput separately.
|
||||
- [x] Device-scoped test/retry/cancel/queued-job routes and native record/table cancellation/reprint review, explicit duplicate-risk consent and retry idempotency.
|
||||
- [ ] Server-side job expiry/document retention and operational alerts.
|
||||
- [ ] PostgreSQL integration and multi-node stress tests; migration/version management.
|
||||
|
||||
@@ -4,7 +4,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>0.1.1-rc.1</version>
|
||||
<version>0.1.2-rc.1</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>
|
||||
|
||||
@@ -166,7 +166,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData field(String name, String label)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(true));
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(true).withMaxLength("deviceName".equals(name) ? 1024 : 200));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,9 +81,9 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
}
|
||||
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion("0.1.0-rc.1").withNamespace("printing").withConfig(config));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "agent_version", "last_seen_at"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at"));
|
||||
devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled"));
|
||||
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "driver_name", "port_name", "status", "formats", "last_seen_at"));
|
||||
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at"));
|
||||
printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved"));
|
||||
printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available"));
|
||||
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id"));
|
||||
@@ -92,6 +92,16 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
jobs.getField("format").withPossibleValueSourceName("printing_format");
|
||||
jobs.getField("status").withPossibleValueSourceName("printing_jobStatus");
|
||||
printers.getField("status").withPossibleValueSourceName("printing_printerStatus");
|
||||
for(QTableMetaData profileTable : List.of(devices, printers))
|
||||
{
|
||||
profileTable.getField("name").withMaxLength(1024);
|
||||
profileTable.getField("display_name").withLabel("Custom name").withMaxLength(512);
|
||||
profileTable.getField("description").withMaxLength(4096);
|
||||
profileTable.getField("location").withMaxLength(512);
|
||||
}
|
||||
devices.getField("computer_name").withLabel("Computer name").withMaxLength(1024);
|
||||
printers.getField("queue_name").withLabel("Windows queue name").withMaxLength(1024);
|
||||
jobs.getField("title").withMaxLength(1024);
|
||||
binaryValues(instance, "printing_enabled", "Disabled", "Enabled");
|
||||
binaryValues(instance, "printing_approved", "Not approved", "Approved");
|
||||
binaryValues(instance, "printing_available", "Not exposed", "Exposed");
|
||||
@@ -106,8 +116,8 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
printers.getField("device_id").withLabel("Print device");
|
||||
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.DEVICE));
|
||||
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.PRINTER));
|
||||
layout(devices, List.of("name", "facility_id", "enabled", "last_seen_at"));
|
||||
layout(printers, List.of("name", "device_id", "status", "enabled", "available", "last_seen_at"));
|
||||
layout(devices, List.of("name", "location", "description", "facility_id", "enabled", "last_seen_at"));
|
||||
layout(printers, List.of("name", "location", "description", "device_id", "status", "enabled", "available", "last_seen_at"));
|
||||
layout(jobs, List.of("title", "device_id", "printer_row_id", "status", "created_at", "format"));
|
||||
instance.addTable(devices);
|
||||
instance.addTable(printers);
|
||||
@@ -184,7 +194,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
table.withSection(new QFieldSection(table.getName() + "_summary", "Summary", new QIcon("print"), Tier.T1, primary));
|
||||
List<String> details = table.getFields().keySet().stream().filter(name -> !primary.contains(name) && !Boolean.TRUE.equals(table.getField(name).getIsHidden())).toList();
|
||||
table.withSection(new QFieldSection(table.getName() + "_details", "Details", new QIcon("info"), Tier.T3, details));
|
||||
table.withSupplementalMetaData(new DefaultColumns(primary));
|
||||
table.withSupplementalMetaData(new DefaultColumns(primary.stream().filter(name -> !"description".equals(name)).toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface
|
||||
config.routes.get(BASE + "/admin/jobs", context -> handle(context, () ->
|
||||
{
|
||||
admin(context);
|
||||
return new JSONObject().put("jobs", service.jobs());
|
||||
String limit = context.queryParam("limit");
|
||||
return service.jobsPage(limit == null ? 200 : Integer.parseInt(limit), context.queryParam("before"));
|
||||
}));
|
||||
config.routes.get(BASE + "/admin/jobs/{jobId}", context -> handle(context, () ->
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ import org.json.JSONObject;
|
||||
public class PrintingService
|
||||
{
|
||||
private static final int MAX_CONTENT_BYTES = 20 * 1024 * 1024;
|
||||
private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id";
|
||||
private final DataSource dataSource;
|
||||
private final String tenantId;
|
||||
|
||||
@@ -76,7 +77,22 @@ public class PrintingService
|
||||
"CREATE TABLE IF NOT EXISTS printing_job (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, title VARCHAR(300) NOT NULL, format VARCHAR(10) NOT NULL, content_hash VARCHAR(64) NOT NULL, content_base64 TEXT NOT NULL, content_length INTEGER NOT NULL, options_json VARCHAR(2000) NOT NULL, idempotency_key VARCHAR(300) NOT NULL, created_at VARCHAR(40) NOT NULL, status VARCHAR(40) NOT NULL, message VARCHAR(2000), spooler_job_id VARCHAR(100), UNIQUE(tenant_id, idempotency_key))",
|
||||
"CREATE TABLE IF NOT EXISTS printing_event (id VARCHAR(100) PRIMARY KEY, job_id VARCHAR(36) NOT NULL REFERENCES printing_job(id), status VARCHAR(40) NOT NULL, event_json VARCHAR(4000) NOT NULL, created_at VARCHAR(40) NOT NULL)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_row_id VARCHAR(36)",
|
||||
"UPDATE printing_job SET printer_row_id=(SELECT p.id FROM printing_printer p WHERE p.device_id=printing_job.device_id AND p.printer_id=printing_job.printer_id AND p.tenant_id=printing_job.tenant_id) WHERE printer_row_id IS NULL"
|
||||
"UPDATE printing_job SET printer_row_id=(SELECT p.id FROM printing_printer p WHERE p.device_id=printing_job.device_id AND p.printer_id=printing_job.printer_id AND p.tenant_id=printing_job.tenant_id) WHERE printer_row_id IS NULL",
|
||||
"ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024)",
|
||||
"ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS display_name VARCHAR(512)",
|
||||
"ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS description VARCHAR(4096)",
|
||||
"ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS location VARCHAR(512)",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024)",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS display_name VARCHAR(512)",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS description VARCHAR(4096)",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS location VARCHAR(512)",
|
||||
"UPDATE printing_device SET computer_name=name WHERE computer_name IS NULL",
|
||||
"UPDATE printing_printer SET queue_name=name WHERE queue_name IS NULL",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_history_idx ON printing_job(tenant_id, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_event_job_idx ON printing_event(job_id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_device_tenant_idx ON printing_device(tenant_id, last_seen_at)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_printer_tenant_idx ON printing_printer(tenant_id, status)"
|
||||
};
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
@@ -88,6 +104,39 @@ public class PrintingService
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
widenColumn(connection, "printing_device", "name", 1024);
|
||||
widenColumn(connection, "printing_enrollment", "device_name", 1024);
|
||||
widenColumn(connection, "printing_printer", "name", 1024);
|
||||
widenColumn(connection, "printing_printer", "driver_name", 1024);
|
||||
widenColumn(connection, "printing_printer", "port_name", 1024);
|
||||
widenColumn(connection, "printing_job", "title", 1024);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Widen fixed application columns without changing identifiers or content.
|
||||
***************************************************************************/
|
||||
private static void widenColumn(Connection connection, String table, String column, int maximum) throws SQLException
|
||||
{
|
||||
boolean widen = false;
|
||||
try(ResultSet columns = connection.getMetaData().getColumns(null, null, "%", "%"))
|
||||
{
|
||||
while(columns.next())
|
||||
{
|
||||
if(table.equalsIgnoreCase(columns.getString("TABLE_NAME")) && column.equalsIgnoreCase(columns.getString("COLUMN_NAME")) && columns.getInt("COLUMN_SIZE") < maximum)
|
||||
{
|
||||
widen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(widen)
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement("ALTER TABLE " + table + " ALTER COLUMN " + column + " SET DATA TYPE VARCHAR(" + maximum + ")"))
|
||||
{
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +222,8 @@ public class PrintingService
|
||||
JSONObject code = codes.getFirst();
|
||||
String id = UUID.randomUUID().toString();
|
||||
String token = secret();
|
||||
update(connection, "INSERT INTO printing_device VALUES (?, ?, ?, ?, ?, ?, ?, 1)", id, tenantId, code.getString("facility_id"), required(request.optString("deviceName"), "deviceName"), required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString());
|
||||
String computerName = required(request.optString("deviceName"), "deviceName");
|
||||
update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)", id, tenantId, code.getString("facility_id"), computerName, required(request.optString("agentVersion"), "agentVersion"), hash(token), Instant.now().toString(), computerName);
|
||||
update(connection, "DELETE FROM printing_enrollment WHERE code_hash=?", codeHash);
|
||||
connection.commit();
|
||||
return (new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5));
|
||||
@@ -214,8 +264,27 @@ public class PrintingService
|
||||
String now = Instant.now().toString();
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
update(connection, "UPDATE printing_device SET last_seen_at=?, agent_version=? WHERE id=?", now, required(request.optString("agentVersion"), "agentVersion"), deviceId);
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
update(connection, "UPDATE printing_device SET last_seen_at=?, agent_version=? WHERE id=?", now, required(request.optString("agentVersion"), "agentVersion"), deviceId);
|
||||
JSONObject profile = request.optJSONObject("profile");
|
||||
if(!request.isNull("profile") && profile == null)
|
||||
{
|
||||
throw new PrintingException(400, "Device profile must be an object");
|
||||
}
|
||||
if(profile != null)
|
||||
{
|
||||
updateProfile(connection, "printing_device", deviceId, profile, "name", "computer_name");
|
||||
}
|
||||
connection.commit();
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
connection.rollback();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
return (new JSONObject().put("serverTime", now));
|
||||
}
|
||||
@@ -246,11 +315,15 @@ public class PrintingService
|
||||
String formats = printer.getJSONArray("formats").toString();
|
||||
String status = printer.optString("status", "UNKNOWN");
|
||||
String now = Instant.now().toString();
|
||||
int changed = update(connection, "UPDATE printing_printer SET name=?, driver_name=?, port_name=?, status=?, formats=?, last_seen_at=?, available=1 WHERE device_id=? AND printer_id=?", name, printer.optString("driverName"), printer.optString("portName"), status, formats, now, deviceId, printerId);
|
||||
String driver = optionalText(printer.optString("driverName"), "driverName", 1024);
|
||||
String port = optionalText(printer.optString("portName"), "portName", 1024);
|
||||
int changed = update(connection, "UPDATE printing_printer SET queue_name=?, name=COALESCE(NULLIF(display_name,''),?), driver_name=?, port_name=?, status=?, formats=?, last_seen_at=?, available=1 WHERE device_id=? AND printer_id=?", name, name, driver, port, status, formats, now, deviceId, printerId);
|
||||
if(changed == 0)
|
||||
{
|
||||
update(connection, "INSERT INTO printing_printer (id, tenant_id, device_id, printer_id, name, driver_name, port_name, status, formats, enabled, last_seen_at, available) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1)", UUID.randomUUID().toString(), tenantId, deviceId, printerId, name, printer.optString("driverName"), printer.optString("portName"), status, formats, now);
|
||||
update(connection, "INSERT INTO printing_printer (id, tenant_id, device_id, printer_id, name, driver_name, port_name, status, formats, enabled, last_seen_at, available, queue_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?)", UUID.randomUUID().toString(), tenantId, deviceId, printerId, name, driver, port, status, formats, now, name);
|
||||
}
|
||||
String rowId = query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).getFirst().getString("id");
|
||||
updateProfile(connection, "printing_printer", rowId, printer, "displayName", "queue_name");
|
||||
}
|
||||
update(connection, "UPDATE printing_device SET last_seen_at=? WHERE id=?", Instant.now().toString(), deviceId);
|
||||
connection.commit();
|
||||
@@ -342,7 +415,7 @@ public class PrintingService
|
||||
// Serialize submissions per device; the unique key also protects cross-device races.
|
||||
requireDevice(connection, deviceId);
|
||||
query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId);
|
||||
List<JSONObject> previous = query(connection, "SELECT * FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
List<JSONObject> previous = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
if(!previous.isEmpty())
|
||||
{
|
||||
JSONObject old = previous.getFirst();
|
||||
@@ -360,7 +433,7 @@ public class PrintingService
|
||||
}
|
||||
String id = UUID.randomUUID().toString();
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?)", id, tenantId, deviceId, printerId, title, format, digest, Base64.getEncoder().encodeToString(content), content.length, options.toString(), key, Instant.now().toString(), printers.getFirst().getString("id"));
|
||||
JSONObject result = job(query(connection, "SELECT * FROM printing_job WHERE id=?", id).getFirst());
|
||||
JSONObject result = job(query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE id=?", id).getFirst());
|
||||
connection.commit();
|
||||
return (result);
|
||||
}
|
||||
@@ -401,7 +474,7 @@ public class PrintingService
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
JSONArray result = new JSONArray();
|
||||
for(JSONObject row : query(connection, "SELECT * FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' ORDER BY created_at, id LIMIT 50", tenantId, deviceId))
|
||||
for(JSONObject row : query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' ORDER BY created_at, id LIMIT 50", tenantId, deviceId))
|
||||
{
|
||||
result.put(job(row));
|
||||
}
|
||||
@@ -420,7 +493,7 @@ public class PrintingService
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
row = deviceJob(connection, deviceId, jobId);
|
||||
row = deviceJob(connection, deviceId, jobId, true);
|
||||
}
|
||||
String state = row.getString("status");
|
||||
if(!Set.of("FAILED", "OUTCOME_UNKNOWN", "SPOOLED", "SPOOLER_COMPLETED").contains(state))
|
||||
@@ -482,7 +555,7 @@ public class PrintingService
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId);
|
||||
List<JSONObject> candidates = query(connection, "SELECT j.* FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') ORDER BY j.created_at, j.id", tenantId, deviceId);
|
||||
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') ORDER BY j.created_at, j.id LIMIT 1", tenantId, deviceId);
|
||||
if(candidates.isEmpty())
|
||||
{
|
||||
connection.commit();
|
||||
@@ -515,7 +588,7 @@ public class PrintingService
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
JSONObject row = deviceJob(connection, deviceId, jobId);
|
||||
JSONObject row = deviceJob(connection, deviceId, jobId, true);
|
||||
if("QUEUED".equals(row.getString("status")))
|
||||
{
|
||||
throw new PrintingException(409, "Claim the job before downloading");
|
||||
@@ -586,7 +659,9 @@ public class PrintingService
|
||||
JSONArray result = new JSONArray();
|
||||
for(JSONObject row : query(connection, "SELECT * FROM printing_device WHERE tenant_id=? ORDER BY name", tenantId))
|
||||
{
|
||||
result.put(new JSONObject().put("deviceId", row.getString("id")).put("facilityId", row.getString("facility_id")).put("deviceName", row.getString("name")).put("agentVersion", row.getString("agent_version")).put("lastSeenAt", row.getString("last_seen_at")).put("enabled", row.getInt("enabled") == 1));
|
||||
result.put(new JSONObject().put("deviceId", row.getString("id")).put("facilityId", row.getString("facility_id")).put("deviceName", row.getString("name")).put("computerName", row.getString("computer_name"))
|
||||
.put("profile", new JSONObject().put("name", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")))
|
||||
.put("agentVersion", row.getString("agent_version")).put("lastSeenAt", row.getString("last_seen_at")).put("enabled", row.getInt("enabled") == 1));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
@@ -605,7 +680,8 @@ public class PrintingService
|
||||
JSONArray result = new JSONArray();
|
||||
for(JSONObject row : query(connection, "SELECT * FROM printing_printer WHERE device_id=? ORDER BY name", deviceId))
|
||||
{
|
||||
result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("name", row.getString("name")).put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at")));
|
||||
result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("name", row.getString("queue_name")).put("displayName", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location"))
|
||||
.put("driverName", row.optString("driver_name")).put("portName", row.optString("port_name")).put("status", row.getString("status")).put("formats", new JSONArray(row.getString("formats"))).put("enabled", row.getInt("enabled") == 1).put("available", row.getInt("available") == 1).put("lastSeenAt", row.getString("last_seen_at")));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
@@ -618,14 +694,59 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
public JSONArray jobs() throws SQLException
|
||||
{
|
||||
return (jobsPage(200, null).getJSONArray("jobs"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Keyset pagination stays bounded as history grows, without document reads.
|
||||
***************************************************************************/
|
||||
public JSONObject jobsPage(int limit, String before) throws SQLException
|
||||
{
|
||||
if(limit < 1 || limit > 200)
|
||||
{
|
||||
throw new PrintingException(400, "Job page limit must be between 1 and 200");
|
||||
}
|
||||
String created = null;
|
||||
String id = null;
|
||||
if(before != null && !before.isEmpty())
|
||||
{
|
||||
if(before.length() > 512)
|
||||
{
|
||||
throw new PrintingException(400, "Invalid job cursor");
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject cursor = new JSONObject(new String(Base64.getUrlDecoder().decode(before), StandardCharsets.UTF_8));
|
||||
created = cursor.getString("createdAt");
|
||||
Instant.parse(created);
|
||||
id = UUID.fromString(cursor.getString("jobId")).toString();
|
||||
}
|
||||
catch(RuntimeException exception)
|
||||
{
|
||||
throw new PrintingException(400, "Invalid job cursor");
|
||||
}
|
||||
}
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
JSONArray result = new JSONArray();
|
||||
for(JSONObject row : query(connection, "SELECT * FROM printing_job WHERE tenant_id=? ORDER BY created_at DESC FETCH FIRST 200 ROWS ONLY", tenantId))
|
||||
String sql = "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=?";
|
||||
List<JSONObject> rows = created == null
|
||||
? query(connection, sql + " ORDER BY created_at DESC, id DESC LIMIT ?", tenantId, limit + 1)
|
||||
: query(connection, sql + " AND (created_at<? OR (created_at=? AND id<?)) ORDER BY created_at DESC, id DESC LIMIT ?", tenantId, created, created, id, limit + 1);
|
||||
for(JSONObject row : rows.subList(0, Math.min(limit, rows.size())))
|
||||
{
|
||||
result.put(job(row));
|
||||
}
|
||||
return (result);
|
||||
JSONObject response = new JSONObject().put("jobs", result).put("nextCursor", JSONObject.NULL);
|
||||
if(rows.size() > limit)
|
||||
{
|
||||
JSONObject last = rows.get(limit - 1);
|
||||
JSONObject cursor = new JSONObject().put("createdAt", last.getString("created_at")).put("jobId", last.getString("id"));
|
||||
response.put("nextCursor", Base64.getUrlEncoder().withoutPadding().encodeToString(cursor.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
return (response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +759,7 @@ public class PrintingService
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT * FROM printing_job WHERE id=? AND tenant_id=?", id, tenantId);
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE id=? AND tenant_id=?", id, tenantId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Job not found");
|
||||
@@ -656,7 +777,7 @@ public class PrintingService
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT * FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + " FROM printing_job WHERE tenant_id=? AND idempotency_key=?", tenantId, key);
|
||||
return (rows.isEmpty() ? null : job(rows.getFirst()));
|
||||
}
|
||||
}
|
||||
@@ -681,7 +802,17 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private JSONObject deviceJob(Connection connection, String deviceId, String jobId) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT * FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId);
|
||||
return (deviceJob(connection, deviceId, jobId, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only content download and explicit retry need the stored document bytes.
|
||||
***************************************************************************/
|
||||
private JSONObject deviceJob(Connection connection, String deviceId, String jobId, boolean includeContent) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT " + JOB_COLUMNS + (includeContent ? ", content_base64" : "") + " FROM printing_job WHERE id=? AND tenant_id=? AND device_id=?", jobId, tenantId, deviceId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Job not found");
|
||||
@@ -760,7 +891,14 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private static String required(String value, String name)
|
||||
{
|
||||
if(value == null || value.isBlank() || value.length() > 300)
|
||||
int maximum = switch(name)
|
||||
{
|
||||
case "name", "deviceName", "title" -> 1024;
|
||||
case "tenantId", "facilityId", "printerId" -> 200;
|
||||
case "agentVersion", "eventId" -> 100;
|
||||
default -> 300;
|
||||
};
|
||||
if(value == null || value.isBlank() || value.length() > maximum)
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name);
|
||||
}
|
||||
@@ -769,6 +907,33 @@ public class PrintingService
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Client-owned metadata synchronizes without changing technical identity.
|
||||
***************************************************************************/
|
||||
private void updateProfile(Connection connection, String table, String id, JSONObject profile, String nameKey, String physicalColumn) throws SQLException
|
||||
{
|
||||
String name = profile.isNull(nameKey) ? null : optionalText(profile.getString(nameKey), nameKey, 512).strip();
|
||||
String description = profile.isNull("description") ? null : optionalText(profile.getString("description"), "description", 4096);
|
||||
String location = profile.isNull("location") ? null : optionalText(profile.getString("location"), "location", 512).strip();
|
||||
update(connection, "UPDATE " + table + " SET display_name=COALESCE(?,display_name), description=COALESCE(?,description), location=COALESCE(?,location), name=COALESCE(NULLIF(COALESCE(?,display_name),'')," + physicalColumn + ") WHERE id=? AND tenant_id=?", name, description, location, name, id, tenantId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reject oversized text before persistence; explicit empty values may clear.
|
||||
***************************************************************************/
|
||||
private static String optionalText(String value, String name, int maximum)
|
||||
{
|
||||
if(value.length() > maximum || value.indexOf('\0') >= 0)
|
||||
{
|
||||
throw new PrintingException(400, "Invalid " + name + "; maximum " + maximum + " characters");
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Generate independent, unguessable enrollment and device credentials.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -61,6 +61,16 @@ class PrintingMigrationTest
|
||||
{
|
||||
statement.execute();
|
||||
}
|
||||
for(String table : List.of("printing_device", "printing_printer"))
|
||||
{
|
||||
for(String column : List.of("display_name", "description", "location", table.equals("printing_device") ? "computer_name" : "queue_name"))
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement("ALTER TABLE " + table + " DROP COLUMN " + column))
|
||||
{
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String suffix : List.of("enrollment", "device", "printer", "job", "event"))
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement("ALTER TABLE printing_" + suffix + " RENAME TO print_" + suffix))
|
||||
@@ -74,6 +84,8 @@ class PrintingMigrationTest
|
||||
restarted.initialize();
|
||||
assertEquals(device.getString("deviceId"), restarted.authenticate(device.getString("deviceToken")));
|
||||
assertEquals(1, restarted.devices().length());
|
||||
assertEquals("station", restarted.devices().getJSONObject(0).getString("computerName"));
|
||||
assertEquals("Labels", restarted.printers(deviceId).getJSONObject(0).getString("name"));
|
||||
assertEquals(jobId, restarted.claim(deviceId).getJSONObject("job").getString("jobId"));
|
||||
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM printing_job j JOIN printing_printer p ON j.printer_row_id=p.id WHERE j.id=?"))
|
||||
{
|
||||
|
||||
@@ -165,6 +165,14 @@ class PrintingProcessesTest
|
||||
assertEquals(PrintingTables.DEVICE, fixture.instance().getProcess("printingEnrollDevice").getTableName());
|
||||
var columns = (PrintingQBitProducer.DefaultColumns) fixture.instance().getTable(PrintingTables.PRINTER).getSupplementalMetaData("nextUi");
|
||||
assertTrue(columns.getDefaultColumns().containsAll(List.of("status", "enabled", "available")));
|
||||
assertTrue(columns.getDefaultColumns().contains("location"));
|
||||
assertFalse(columns.getDefaultColumns().contains("description"));
|
||||
assertEquals(4096, fixture.instance().getTable(PrintingTables.PRINTER).getField("description").getMaxLength());
|
||||
assertEquals(1024, fixture.instance().getTable(PrintingTables.PRINTER).getField("queue_name").getMaxLength());
|
||||
fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("displayName", "Shipping desk").put("description", "Shared dispatch printer").put("location", "Dock 2").put("formats", new JSONArray(List.of("PDF")))));
|
||||
var labeledChoices = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withDefaultQueryFilter(filter)).getResults();
|
||||
assertTrue(labeledChoices.stream().anyMatch(value -> value.getLabel().equals("Shipping desk")));
|
||||
assertTrue(fixture.instance().getTable(PrintingTables.PRINTER).getDisabledCapabilities().containsAll(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.allWriteCapabilities()));
|
||||
assertTrue(fixture.instance().getTable(PrintingTables.PRINTER).getField("tenant_id").getIsHidden());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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.sql.PreparedStatement;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Client-owned profiles retain identity while bounded history scales with rows.
|
||||
*******************************************************************************/
|
||||
class PrintingProfilesTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Long labels round trip and older clients cannot erase synchronized text.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void profilesPreserveIdentityApprovalAndFallback() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
String computer = "station-" + "c".repeat(900);
|
||||
JSONObject enrollment = service.enroll(service.createEnrollment("warehouse", computer).put("deviceName", computer).put("agentVersion", "test"));
|
||||
String device = enrollment.getString("deviceId");
|
||||
String queue = "queue-" + "q".repeat(1000);
|
||||
JSONObject printer = printer(queue).put("displayName", "p".repeat(512)).put("description", "d".repeat(4096)).put("location", "l".repeat(512));
|
||||
service.inventory(device, new JSONArray().put(printer));
|
||||
service.enablePrinter(device, "queue-id", true);
|
||||
JSONObject profile = new JSONObject().put("name", "n".repeat(512)).put("description", "d".repeat(4096)).put("location", "l".repeat(512));
|
||||
service.heartbeat(device, new JSONObject().put("agentVersion", "test").put("profile", profile));
|
||||
PrintingService restarted = new PrintingService(source, "tenant-a");
|
||||
restarted.initialize();
|
||||
assertEquals(device, restarted.authenticate(enrollment.getString("deviceToken")));
|
||||
assertEquals(profile.getString("name"), restarted.devices().getJSONObject(0).getString("deviceName"));
|
||||
assertEquals(computer, restarted.devices().getJSONObject(0).getString("computerName"));
|
||||
assertTrue(profile.similar(restarted.devices().getJSONObject(0).getJSONObject("profile")));
|
||||
restarted.heartbeat(device, new JSONObject().put("agentVersion", "older"));
|
||||
restarted.inventory(device, new JSONArray().put(printer(queue)));
|
||||
JSONObject observed = restarted.printers(device).getJSONObject(0);
|
||||
assertEquals(queue, observed.getString("name"));
|
||||
assertEquals("p".repeat(512), observed.getString("displayName"));
|
||||
assertTrue(observed.getBoolean("enabled"));
|
||||
assertEquals(4096, observed.getString("description").length());
|
||||
restarted.heartbeat(device, new JSONObject().put("agentVersion", "test").put("profile", new JSONObject().put("name", "").put("description", "").put("location", JSONObject.NULL)));
|
||||
restarted.inventory(device, new JSONArray().put(printer(queue).put("displayName", "").put("description", "").put("location", "")));
|
||||
assertEquals(computer, restarted.devices().getJSONObject(0).getString("deviceName"));
|
||||
assertEquals("l".repeat(512), restarted.devices().getJSONObject(0).getJSONObject("profile").getString("location"));
|
||||
assertEquals("", restarted.printers(device).getJSONObject(0).getString("description"));
|
||||
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT name FROM printing_printer WHERE device_id=?"))
|
||||
{
|
||||
statement.setString(1, device);
|
||||
try(var rows = statement.executeQuery())
|
||||
{
|
||||
rows.next();
|
||||
assertEquals(queue, rows.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Invalid profiles fail atomically without revoking approval or exposure.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void rejectsOversizedTextAndCrossTenantWrites() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
String device = enroll(service);
|
||||
service.inventory(device, new JSONArray().put(printer("queue")));
|
||||
service.enablePrinter(device, "queue-id", true);
|
||||
for(JSONObject invalid : new JSONObject[] {new JSONObject().put("name", "n".repeat(513)), new JSONObject().put("description", "d".repeat(4097)), new JSONObject().put("location", "l".repeat(513))})
|
||||
{
|
||||
assertEquals(400, assertThrows(PrintingException.class, () -> service.heartbeat(device, new JSONObject().put("agentVersion", "test").put("profile", invalid))).getStatus());
|
||||
}
|
||||
assertThrows(PrintingException.class, () -> service.inventory(device, new JSONArray().put(printer("q".repeat(1025)))));
|
||||
assertThrows(PrintingException.class, () -> service.inventory(device, new JSONArray().put(printer("queue").put("displayName", "n".repeat(513)))));
|
||||
JSONObject observed = service.printers(device).getJSONObject(0);
|
||||
assertTrue(observed.getBoolean("enabled"));
|
||||
assertTrue(observed.getBoolean("available"));
|
||||
assertEquals("queue", observed.getString("name"));
|
||||
assertEquals(1024, service.submit(request(device).put("title", "t".repeat(1024))).getString("title").length());
|
||||
assertThrows(PrintingException.class, () -> service.submit(request(device).put("title", "t".repeat(1025))));
|
||||
PrintingService other = new PrintingService(source, "tenant-b");
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> other.heartbeat(device, new JSONObject().put("agentVersion", "test").put("profile", new JSONObject().put("name", "foreign")))).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> other.inventory(device, new JSONArray().put(printer("foreign")))).getStatus());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ten thousand historical rows paginate once each and dispatch stays bounded.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void paginatesTenThousandJobsWithoutDocumentsOrSkippedRows() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
String device = enroll(service);
|
||||
service.inventory(device, new JSONArray().put(printer("queue")));
|
||||
service.enablePrinter(device, "queue-id", true);
|
||||
String seed = service.submit(request(device)).getString("jobId");
|
||||
try(Connection connection = source.getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, status, printer_row_id) SELECT ?, tenant_id, device_id, printer_id, ?, format, content_hash, content_base64, content_length, options_json, ?, ?, ?, printer_row_id FROM printing_job WHERE id=?"))
|
||||
{
|
||||
connection.setAutoCommit(false);
|
||||
for(int index = 0; index < 10000; index++)
|
||||
{
|
||||
statement.setString(1, UUID.nameUUIDFromBytes(("job-" + index).getBytes(StandardCharsets.UTF_8)).toString());
|
||||
statement.setString(2, "Synthetic job " + index);
|
||||
statement.setString(3, "volume-" + index);
|
||||
statement.setString(4, java.time.Instant.parse("2026-01-01T00:00:00Z").plusSeconds(index).toString());
|
||||
statement.setString(5, index < 9900 ? "SPOOLER_COMPLETED" : "QUEUED");
|
||||
statement.setString(6, seed);
|
||||
statement.addBatch();
|
||||
}
|
||||
statement.executeBatch();
|
||||
connection.commit();
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
String cursor = null;
|
||||
do
|
||||
{
|
||||
JSONObject page = service.jobsPage(137, cursor);
|
||||
JSONArray jobs = page.getJSONArray("jobs");
|
||||
assertTrue(jobs.length() <= 137);
|
||||
for(int index = 0; index < jobs.length(); index++)
|
||||
{
|
||||
JSONObject job = jobs.getJSONObject(index);
|
||||
assertTrue(ids.add(job.getString("jobId")));
|
||||
assertFalse(job.has("contentBase64"));
|
||||
}
|
||||
cursor = page.isNull("nextCursor") ? null : page.getString("nextCursor");
|
||||
}
|
||||
while(cursor != null);
|
||||
assertEquals(10001, ids.size());
|
||||
assertEquals(200, service.jobs().length());
|
||||
assertEquals(50, service.queuedJobs(device).length());
|
||||
JSONObject claimed = service.claim(device).getJSONObject("job");
|
||||
assertEquals("Synthetic job 9900", claimed.getString("title"));
|
||||
assertEquals(claimed.getString("jobId"), service.claim(device).getJSONObject("job").getString("jobId"));
|
||||
assertThrows(PrintingException.class, () -> service.jobsPage(201, null));
|
||||
assertEquals(0, new PrintingService(source, "tenant-b").jobsPage(200, null).getJSONArray("jobs").length());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Isolated synthetic database.
|
||||
***************************************************************************/
|
||||
private static DriverDataSource source()
|
||||
{
|
||||
return (new DriverDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A single stable printer identifier independent of labels.
|
||||
***************************************************************************/
|
||||
private static JSONObject printer(String queue)
|
||||
{
|
||||
return (new JSONObject().put("printerId", "queue-id").put("name", queue).put("status", "READY").put("formats", new JSONArray().put("ZPL")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Enroll a synthetic computer.
|
||||
***************************************************************************/
|
||||
private static String enroll(PrintingService service) throws Exception
|
||||
{
|
||||
return (service.enroll(service.createEnrollment("warehouse", "computer").put("deviceName", "computer").put("agentVersion", "test")).getString("deviceId"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Small synthetic document; these tests never call a Windows printer.
|
||||
***************************************************************************/
|
||||
private static JSONObject request(String device)
|
||||
{
|
||||
return (new JSONObject().put("deviceId", device).put("printerId", "queue-id").put("title", "Synthetic job").put("format", "ZPL")
|
||||
.put("contentBase64", Base64.getEncoder().encodeToString("^XA^XZ".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", UUID.randomUUID().toString()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user