From 700c86303aa3c6d4f5dbabc998203d62aca9be40 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 12:41:58 +0200 Subject: [PATCH 01/96] Add generated TUS protocol contract canary --- .../java/io/tus/java/client/TusClient.java | 2 +- .../java/io/tus/java/client/TusProtocol.java | 17 + .../client/GeneratedTusProtocolContract.java | 461 ++++++++++++++++++ .../TestGeneratedTusProtocolContract.java | 115 +++++ 4 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/tus/java/client/TusProtocol.java create mode 100644 src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java create mode 100644 src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index a5cac6ad..4b56a474 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -17,7 +17,7 @@ public class TusClient { * Version of the tus protocol used by the client. The remote server needs to support this * version, too. */ - public static final String TUS_VERSION = "1.0.0"; + public static final String TUS_VERSION = TusProtocol.DEFAULT_PROTOCOL_VERSION; private URL uploadCreationURL; private Proxy proxy; diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java new file mode 100644 index 00000000..b4a53891 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -0,0 +1,17 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.java.client; + +/** + * Generated TUS protocol constants used by the runtime client. + */ +final class TusProtocol { + static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; + + private TusProtocol() { + } +} diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java new file mode 100644 index 00000000..a3e9b0bc --- /dev/null +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -0,0 +1,461 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.java.client; + +/** + * Generated TUS protocol contract fixture used by tests. + */ +final class GeneratedTusProtocolContract { + static final GeneratedTusWireVersion[] WIRE_VERSIONS = new GeneratedTusWireVersion[] { + new GeneratedTusWireVersion( + true, + "1.0.0" + ), + }; + + static final GeneratedTusProtocolOperation[] OPERATIONS = new GeneratedTusProtocolOperation[] { + new GeneratedTusProtocolOperation( + "discoverTusCapabilities", + "capability-discovery", + "OPTIONS", + "/resumable/files/", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[0] + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Extension", + "tus-extension", + true + ), + new GeneratedTusHeaderField( + "Tus-Max-Size", + "tus-max-size", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Tus-Version", + "tus-version", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "createTusUpload", + "creation", + "POST", + "/resumable/files/", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Defer-Length", + "upload-defer-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 201, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Location", + "location", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "getTusUploadOffset", + "offset-discovery", + "HEAD", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Defer-Length", + "upload-defer-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "patchTusUpload", + "upload-chunk", + "PATCH", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "binary", + "application/offset+octet-stream", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Content-Type", + "content-type", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 204, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "terminateTusUpload", + "termination", + "DELETE", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 204, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "downloadTusUpload", + "download", + "GET", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[0] + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "binary", + new GeneratedTusHeaderVariant[0] + ), + } + ), + }; + + static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { + new GeneratedTusClientFeature( + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + } + ), + new GeneratedTusClientFeature( + "terminateUpload", + new String[] { + "terminateTusUpload", + }, + new String[] { + "retry-with-backoff", + } + ), + }; + + private GeneratedTusProtocolContract() { + } + + /** + * Generated wire-version fixture. + */ + static final class GeneratedTusWireVersion { + final boolean defaultVersion; + final String value; + + GeneratedTusWireVersion(boolean defaultVersion, String value) { + this.defaultVersion = defaultVersion; + this.value = value; + } + } + + /** + * Generated HTTP header field fixture. + */ + static final class GeneratedTusHeaderField { + final String displayName; + final String name; + final boolean required; + + GeneratedTusHeaderField(String displayName, String name, boolean required) { + this.displayName = displayName; + this.name = name; + this.required = required; + } + } + + /** + * Generated alternative HTTP header set fixture. + */ + static final class GeneratedTusHeaderVariant { + final GeneratedTusHeaderField[] fields; + + GeneratedTusHeaderVariant(GeneratedTusHeaderField[] fields) { + this.fields = fields; + } + } + + /** + * Generated request contract fixture. + */ + static final class GeneratedTusRequestContract { + final String bodyKind; + final String contentType; + final GeneratedTusHeaderVariant[] headerVariants; + + GeneratedTusRequestContract( + String bodyKind, + String contentType, + GeneratedTusHeaderVariant[] headerVariants) { + this.bodyKind = bodyKind; + this.contentType = contentType; + this.headerVariants = headerVariants; + } + } + + /** + * Generated response contract fixture. + */ + static final class GeneratedTusResponseContract { + final int statusCode; + final String bodyKind; + final GeneratedTusHeaderVariant[] headerVariants; + + GeneratedTusResponseContract( + int statusCode, + String bodyKind, + GeneratedTusHeaderVariant[] headerVariants) { + this.statusCode = statusCode; + this.bodyKind = bodyKind; + this.headerVariants = headerVariants; + } + } + + /** + * Generated protocol operation fixture. + */ + static final class GeneratedTusProtocolOperation { + final String operationId; + final String role; + final String method; + final String path; + final GeneratedTusRequestContract request; + final GeneratedTusResponseContract[] responses; + + GeneratedTusProtocolOperation( + String operationId, + String role, + String method, + String path, + GeneratedTusRequestContract request, + GeneratedTusResponseContract[] responses) { + this.operationId = operationId; + this.role = role; + this.method = method; + this.path = path; + this.request = request; + this.responses = responses; + } + } + + /** + * Generated client feature fixture. + */ + static final class GeneratedTusClientFeature { + final String featureId; + final String[] operationIds; + final String[] primitives; + + GeneratedTusClientFeature(String featureId, String[] operationIds, String[] primitives) { + this.featureId = featureId; + this.operationIds = operationIds; + this.primitives = primitives; + } + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java new file mode 100644 index 00000000..9ab98919 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -0,0 +1,115 @@ +package io.tus.java.client; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests the generated API2 protocol contract canary. + */ +public class TestGeneratedTusProtocolContract { + + /** + * Verifies the runtime constant is sourced from the generated protocol fixture. + */ + @Test + public void testDefaultProtocolVersionMatchesRuntimeConstant() { + String generatedDefault = null; + int defaultCount = 0; + + for (GeneratedTusProtocolContract.GeneratedTusWireVersion wireVersion + : GeneratedTusProtocolContract.WIRE_VERSIONS) { + if (wireVersion.defaultVersion) { + defaultCount++; + generatedDefault = wireVersion.value; + } + } + + assertEquals(1, defaultCount); + assertEquals("1.0.0", generatedDefault); + assertEquals(generatedDefault, TusProtocol.DEFAULT_PROTOCOL_VERSION); + assertEquals(generatedDefault, TusClient.TUS_VERSION); + } + + /** + * Verifies generated request-header variants retain creation requirements. + */ + @Test + public void testCreateUploadOperationKeepsRequiredHeaders() { + GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation = + findOperation("createTusUpload"); + + assertEquals("POST", operation.method); + assertEquals("/resumable/files/", operation.path); + assertEquals(2, operation.request.headerVariants.length); + assertTrue(hasRequiredHeader(operation.request.headerVariants[0], "tus-resumable")); + assertTrue(hasRequiredHeader(operation.request.headerVariants[0], "upload-length")); + assertTrue(hasRequiredHeader(operation.request.headerVariants[1], "tus-resumable")); + assertTrue(hasRequiredHeader(operation.request.headerVariants[1], "upload-defer-length")); + } + + /** + * Verifies the generated high-level lifecycle feature points at raw protocol operations. + */ + @Test + public void testSingleUploadLifecycleFeatureReferencesProtocolOperations() { + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature("singleUploadLifecycle"); + + assertContains(feature.operationIds, "createTusUpload"); + assertContains(feature.operationIds, "getTusUploadOffset"); + assertContains(feature.operationIds, "patchTusUpload"); + assertContains(feature.primitives, "store-resume-url"); + assertContains(feature.primitives, "emit-progress"); + } + + private static GeneratedTusProtocolContract.GeneratedTusProtocolOperation findOperation( + String operationId) { + for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation + : GeneratedTusProtocolContract.OPERATIONS) { + if (operation.operationId.equals(operationId)) { + return operation; + } + } + + throw new AssertionError("Missing generated TUS operation: " + operationId); + } + + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( + String featureId) { + for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature + : GeneratedTusProtocolContract.CLIENT_FEATURES) { + if (feature.featureId.equals(featureId)) { + return feature; + } + } + + throw new AssertionError("Missing generated TUS client feature: " + featureId); + } + + private static boolean hasRequiredHeader( + GeneratedTusProtocolContract.GeneratedTusHeaderVariant variant, + String headerName) { + assertNotNull(variant); + + for (GeneratedTusProtocolContract.GeneratedTusHeaderField field : variant.fields) { + if (field.required && field.name.equals(headerName)) { + return true; + } + } + + return false; + } + + private static void assertContains(String[] values, String expected) { + for (String value : values) { + if (value.equals(expected)) { + return; + } + } + + throw new AssertionError("Missing generated value: " + expected); + } +} From 8ad4983b9445ffbc9616b3d3658a84f6f714cc11 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 13:10:01 +0200 Subject: [PATCH 02/96] Allow manual Java client workflow runs --- .github/workflows/lintChanges.yml | 2 ++ .github/workflows/tests.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/lintChanges.yml b/.github/workflows/lintChanges.yml index 2b7069dc..ab0b8cd9 100644 --- a/.github/workflows/lintChanges.yml +++ b/.github/workflows/lintChanges.yml @@ -1,11 +1,13 @@ name: Lint Java Code on: + workflow_dispatch: push: branches: - main pull_request: types: - opened + - ready_for_review - synchronize - unlabeled jobs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84d25cc7..e3722daa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,12 +4,14 @@ name: Tests on: + workflow_dispatch: push: branches: - main pull_request: types: - opened + - ready_for_review - synchronize - unlabeled jobs: From 19adf55ea9341ae3e6839e04fd351bba27e5d699 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 21:18:33 +0200 Subject: [PATCH 03/96] Regenerate TUS protocol contract fixture --- .../client/GeneratedTusProtocolContract.java | 110 ++++++++++++++++++ .../TestGeneratedTusProtocolContract.java | 36 +++++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index a3e9b0bc..fc8000f4 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -108,6 +108,49 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Concat", + "upload-concat", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + false + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Concat", + "upload-concat", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + false + ), + } + ), } ), new GeneratedTusResponseContract[] { @@ -328,12 +371,79 @@ final class GeneratedTusProtocolContract { "abort-current-request", } ), + new GeneratedTusClientFeature( + "resumeUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + } + ), + new GeneratedTusClientFeature( + "deferredLengthUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + "creationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + } + ), + new GeneratedTusClientFeature( + "parallelUploadConcat", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "concatenate-partial-uploads", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + "retryOffsetRecovery", + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + } + ), new GeneratedTusClientFeature( "terminateUpload", new String[] { "terminateTusUpload", }, new String[] { + "terminate-upload", "retry-with-backoff", } ), diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java index 9ab98919..5c5a5cd2 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -43,11 +43,17 @@ public void testCreateUploadOperationKeepsRequiredHeaders() { assertEquals("POST", operation.method); assertEquals("/resumable/files/", operation.path); - assertEquals(2, operation.request.headerVariants.length); - assertTrue(hasRequiredHeader(operation.request.headerVariants[0], "tus-resumable")); - assertTrue(hasRequiredHeader(operation.request.headerVariants[0], "upload-length")); - assertTrue(hasRequiredHeader(operation.request.headerVariants[1], "tus-resumable")); - assertTrue(hasRequiredHeader(operation.request.headerVariants[1], "upload-defer-length")); + assertRequiredHeaderVariant( + operation.request.headerVariants, "tus-resumable", "upload-length"); + assertRequiredHeaderVariant( + operation.request.headerVariants, "tus-resumable", "upload-defer-length"); + assertRequiredHeaderVariant( + operation.request.headerVariants, + "tus-resumable", + "upload-concat", + "upload-length"); + assertRequiredHeaderVariant( + operation.request.headerVariants, "tus-resumable", "upload-concat"); } /** @@ -103,6 +109,26 @@ private static boolean hasRequiredHeader( return false; } + private static void assertRequiredHeaderVariant( + GeneratedTusProtocolContract.GeneratedTusHeaderVariant[] variants, + String... headerNames) { + for (GeneratedTusProtocolContract.GeneratedTusHeaderVariant variant : variants) { + boolean hasAllHeaders = true; + for (String headerName : headerNames) { + if (!hasRequiredHeader(variant, headerName)) { + hasAllHeaders = false; + break; + } + } + + if (hasAllHeaders) { + return; + } + } + + throw new AssertionError("Missing generated header variant"); + } + private static void assertContains(String[] values, String expected) { for (String value : values) { if (value.equals(expected)) { From 194b752ebbf7334b663e13afbc37fa943faff4c3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 21:22:43 +0200 Subject: [PATCH 04/96] Fix generated contract lint --- .../io/tus/java/client/TestGeneratedTusProtocolContract.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java index 5c5a5cd2..6d57686e 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -4,7 +4,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; /** * Tests the generated API2 protocol contract canary. From 9d1277f83c652b7dfbcc0fb87149a83ffa20d52a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 22:12:27 +0200 Subject: [PATCH 05/96] Regenerate TUS feature contract fixture --- .../client/GeneratedTusProtocolContract.java | 566 +++++++++++++++++- 1 file changed, 565 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index fc8000f4..567d7385 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -356,7 +356,37 @@ final class GeneratedTusProtocolContract { static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "singleUploadLifecycle", + }, + "covered-by-generated-scenario" + ), + "Create an upload, store its URL, upload bytes, and finish successfully.", "singleUploadLifecycle", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "open-input-source", + "", + "Open the caller input as a sliceable source." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the remote upload resource." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes until the accepted offset reaches the known length." + ), + }, new String[] { "createTusUpload", "getTusUploadOffset", @@ -372,7 +402,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" + ), + "Resume a stored upload URL by discovering the remote offset before patching.", "resumeUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "resume-from-previous-upload", + "", + "Load a stored upload URL selected by fingerprint." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Read the server offset for the stored upload URL." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Continue uploading from the discovered offset." + ), + }, new String[] { "getTusUploadOffset", "patchTusUpload", @@ -384,7 +444,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "deferredLengthUpload", + }, + "covered-by-generated-scenario" + ), + "Create an upload without a known length and declare the length on final PATCH.", "deferredLengthUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the upload with deferred length." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "defer-upload-length", + "", + "Track the source until the final chunk reveals the total size." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Declare Upload-Length on the final chunk request." + ), + }, new String[] { "createTusUpload", "patchTusUpload", @@ -395,7 +485,30 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "creationWithUpload", + }, + "covered-by-generated-scenario" + ), + "Send the first bytes on the creation request when the server/client support it.", "creationWithUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the upload while streaming the initial body." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "upload-during-creation", + "", + "Interpret the creation response as an accepted offset." + ), + }, new String[] { "createTusUpload", }, @@ -405,7 +518,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "overridePatchMethod", + }, + "covered-by-generated-scenario" + ), + "Tunnel PATCH through POST with the method-override header.", "overridePatchMethod", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Resume from the upload URL before sending bytes." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "override-patch-method", + "", + "Replace PATCH with POST while preserving the protocol operation intent." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes through the overridden request." + ), + }, new String[] { "getTusUploadOffset", "patchTusUpload", @@ -415,7 +558,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "parallelUploadConcat", + }, + "covered-by-generated-scenario" + ), + "Split one input into partial uploads and concatenate their upload URLs.", "parallelUploadConcat", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "split-parallel-upload-boundaries", + "", + "Split the input into stable byte ranges." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create partial uploads for each range." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "concatenate-partial-uploads", + "", + "Create the final upload from completed partial upload URLs." + ), + }, new String[] { "createTusUpload", "patchTusUpload", @@ -423,10 +596,41 @@ final class GeneratedTusProtocolContract { new String[] { "concatenate-partial-uploads", "emit-progress", + "split-parallel-upload-boundaries", } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Recover from a failed chunk by reading the server offset before retrying.", "retryOffsetRecovery", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Attempt the chunk upload." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "recover-offset-after-error", + "", + "Discover the accepted offset after a retryable failure." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Use HEAD to recover the offset before retrying PATCH." + ), + }, new String[] { "createTusUpload", "getTusUploadOffset", @@ -438,7 +642,30 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "terminateWithRetry", + }, + "covered-by-generated-scenario" + ), + "Terminate an upload resource and retry retryable termination failures.", "terminateUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "terminate-upload", + "", + "Choose server-side termination for an upload URL." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "terminateTusUpload", + "", + "", + "Delete the upload resource." + ), + }, new String[] { "terminateTusUpload", }, @@ -447,6 +674,294 @@ final class GeneratedTusProtocolContract { "retry-with-backoff", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Abort the active request, pending retry timer, and any partial uploads.", + "abortUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "abort-current-request", + "", + "Cancel in-flight transport work without emitting user callbacks after abort." + ), + }, + new String[0], + new String[] { + "abort-current-request", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Expose progress and accepted-chunk callbacks from runtime upload activity.", + "uploadCallbacks", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-progress", + "", + "Report bytes sent against known or deferred length." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-chunk-complete", + "", + "Report chunk size, accepted offset, and total size after server acceptance." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-upload-url", + "", + "Notify once a usable upload URL is known." + ), + }, + new String[0], + new String[] { + "emit-progress", + "emit-chunk-complete", + "emit-upload-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Run before-request, after-response, and custom retry hooks around transport.", + "requestLifecycleHooks", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "run-request-hooks", + "", + "Call user hooks around each HTTP request/response pair." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "customize-retry", + "", + "Let user retry policy override default retry decisions." + ), + }, + new String[0], + new String[] { + "customize-retry", + "run-request-hooks", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Persist, find, resume, and optionally remove upload URLs by fingerprint.", + "resumeUrlStorage", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "fingerprint-input", + "", + "Derive a stable key for the input when possible." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-resume-url", + "", + "Persist upload URLs and partial-upload URLs for future resumption." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "remove-stored-url-on-success", + "", + "Remove stored upload URLs when configured after success or invalidation." + ), + }, + new String[0], + new String[] { + "fingerprint-input", + "store-resume-url", + "remove-stored-url-on-success", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Support the reference client input/source families across runtimes.", + "inputSources", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-browser-file", + "", + "Read browser Blob/File and ArrayBuffer-family inputs." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-node-stream", + "", + "Read Node streams when size and chunk constraints are satisfied." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-web-stream", + "", + "Read Web Streams with deferred or configured size." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-node-file", + "", + "Read filesystem paths and fs streams, including parallel ranges." + ), + }, + new String[0], + new String[] { + "read-browser-file", + "read-node-file", + "read-node-stream", + "read-web-stream", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Support browser and file-backed URL storage implementations.", + "urlStorageBackends", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-browser-url", + "", + "Persist upload records in browser localStorage." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-file-url", + "", + "Persist upload records in the Node file store." + ), + }, + new String[0], + new String[] { + "store-browser-url", + "store-file-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Select between tus v1 and supported IETF draft client protocol modes.", + "protocolVersionSelection", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "select-client-protocol", + "", + "Choose request headers and response expectations for the selected protocol." + ), + }, + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Normalize relative Location headers against the request endpoint.", + "relativeLocationResolution", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "resolve-relative-location", + "", + "Resolve server Location headers with the creation endpoint as origin." + ), + }, + new String[] { + "createTusUpload", + }, + new String[] { + "resolve-relative-location", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Validate option combinations before starting runtime work.", + "startOptionValidation", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "validate-start-options", + "", + "Reject missing inputs and incompatible parallel/deferred/resume options." + ), + }, + new String[0], + new String[] { + "validate-start-options", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Attach request, response, status, body, and request ID context to errors.", + "detailedErrors", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "report-detailed-errors", + "", + "Return user-facing errors with enough transport context for debugging." + ), + }, + new String[0], + new String[] { + "report-detailed-errors", + } + ), }; private GeneratedTusProtocolContract() { @@ -558,14 +1073,63 @@ static final class GeneratedTusProtocolOperation { * Generated client feature fixture. */ static final class GeneratedTusClientFeature { + final GeneratedTusClientFeatureConformance conformance; + final String description; final String featureId; + final GeneratedTusClientFeatureFlowStep[] flow; final String[] operationIds; final String[] primitives; - GeneratedTusClientFeature(String featureId, String[] operationIds, String[] primitives) { + GeneratedTusClientFeature( + GeneratedTusClientFeatureConformance conformance, + String description, + String featureId, + GeneratedTusClientFeatureFlowStep[] flow, + String[] operationIds, + String[] primitives) { + this.conformance = conformance; + this.description = description; this.featureId = featureId; + this.flow = flow; this.operationIds = operationIds; this.primitives = primitives; } } + + /** + * Generated client feature conformance coverage. + */ + static final class GeneratedTusClientFeatureConformance { + final String[] scenarioIds; + final String status; + + GeneratedTusClientFeatureConformance(String[] scenarioIds, String status) { + this.scenarioIds = scenarioIds; + this.status = status; + } + } + + /** + * Generated client feature flow step. + */ + static final class GeneratedTusClientFeatureFlowStep { + final String kind; + final String operationId; + final String primitive; + final String condition; + final String summary; + + GeneratedTusClientFeatureFlowStep( + String kind, + String operationId, + String primitive, + String condition, + String summary) { + this.kind = kind; + this.operationId = operationId; + this.primitive = primitive; + this.condition = condition; + this.summary = summary; + } + } } From 210974e28e1c8d0486e0ee84912b5bacac952ee5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 11:35:33 +0200 Subject: [PATCH 06/96] Regenerate upload body protocol fixture --- .../client/GeneratedTusProtocolContract.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 567d7385..4ac47ba2 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -517,6 +517,39 @@ final class GeneratedTusProtocolContract { "emit-progress", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "uploadBodyHeaders", + }, + "covered-by-generated-scenario" + ), + "Send protocol-specific upload body headers whenever the client transmits file bytes.", + "uploadBodyHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "send-upload-body-headers", + "", + "Attach the protocol-specific upload body content type when a request has bytes." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with the protocol-specific body headers." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { From 6e7a32a3aa6833131e9c40017f1ec289aab8e770 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 22:39:50 +0200 Subject: [PATCH 07/96] Assert generated TUS upload events --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 4ac47ba2..00c3cc59 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -730,8 +730,12 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "singleUploadLifecycle", + "creationWithUpload", + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" ), "Expose progress and accepted-chunk callbacks from runtime upload activity.", "uploadCallbacks", From d97c79c1f732f60a6b92c0d2866947a5d4bfcdec Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 23:19:53 +0200 Subject: [PATCH 08/96] Cover TUS request lifecycle conformance --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 00c3cc59..08a16376 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -771,8 +771,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "requestLifecycleHooks", + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" ), "Run before-request, after-response, and custom retry hooks around transport.", "requestLifecycleHooks", From d8b4e3856d4348596be53aab528538f6a880c6c0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:06:10 +0200 Subject: [PATCH 09/96] Cover TUS abort conformance --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 08a16376..74302ada 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -709,8 +709,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "abortUpload", + }, + "covered-by-generated-scenario" ), "Abort the active request, pending retry timer, and any partial uploads.", "abortUpload", From a211c170de64f9820abbd0c5b7e100bdaab2cdc3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:14:09 +0200 Subject: [PATCH 10/96] Cover TUS URL storage conformance --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 74302ada..5637c3e6 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -805,8 +805,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "singleUploadLifecycle", + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" ), "Persist, find, resume, and optionally remove upload URLs by fingerprint.", "resumeUrlStorage", From 22e352beda1ed800c1b4d4ba3313e89af1315e2c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:19:28 +0200 Subject: [PATCH 11/96] Cover TUS relative Location conformance --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 5637c3e6..088580d5 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -944,8 +944,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "relativeLocationResolution", + }, + "covered-by-generated-scenario" ), "Normalize relative Location headers against the request endpoint.", "relativeLocationResolution", From 2c32dd5b5b211f5e4ea7cd524e9f19b61ffd796b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:48:41 +0200 Subject: [PATCH 12/96] Refresh TUS input source contract --- .../tus/java/client/GeneratedTusProtocolContract.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 088580d5..1af62d52 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -845,8 +845,14 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "arrayBufferInput", + "arrayBufferViewInput", + "webReadableStreamInput", + "nodeReadableStreamInput", + "nodePathInput", + }, + "covered-by-generated-scenario" ), "Support the reference client input/source families across runtimes.", "inputSources", From db63d33c96bc53f6b8a4d7e91f061f7c5341a67d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 18:10:38 +0200 Subject: [PATCH 13/96] Refresh TUS retry state contract --- .../client/GeneratedTusProtocolContract.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 1af62d52..9a50e54e 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -674,6 +674,41 @@ final class GeneratedTusProtocolContract { "recover-offset-after-error", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Schedule retry timers and reset retry attempts after accepted progress.", + "retryStateTransitions", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "schedule-retry-timer", + "", + "Consume the current retry delay and restart the upload after that timer fires." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "reset-retry-attempt-after-progress", + "", + "Reset retry attempts once a later retry observes server-side offset progress." + ), + }, + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "schedule-retry-timer", + "reset-retry-attempt-after-progress", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { From 296da7cd0e0b049e47bca7b7d1489bdc5e099b03 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 20:09:07 +0200 Subject: [PATCH 14/96] Refresh TUS URL storage contract --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 9a50e54e..48580efe 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -931,8 +931,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "webStorageUrlStorageBackend", + "fileUrlStorageBackend", + }, + "covered-by-generated-scenario" ), "Support browser and file-backed URL storage implementations.", "urlStorageBackends", From c9338d9ed35c41c65e82875588812b353713524f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 21:06:25 +0200 Subject: [PATCH 15/96] Refresh TUS protocol selection contract --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 48580efe..c33f1f1c 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -963,8 +963,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "ietfDraft05CreationWithUpload", + "ietfDraft03ResumeWithoutKnownLength", + }, + "covered-by-generated-scenario" ), "Select between tus v1 and supported IETF draft client protocol modes.", "protocolVersionSelection", From bead997e74f2906dd572b23ffdb8fe72042bfb66 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 22:26:26 +0200 Subject: [PATCH 16/96] Refresh TUS start validation contract --- .../java/client/GeneratedTusProtocolContract.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index c33f1f1c..cbcc5661 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1016,8 +1016,18 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "startValidationMissingInput", + "startValidationMissingEndpointOrUploadUrl", + "startValidationUnsupportedProtocol", + "startValidationRetryDelaysNotArray", + "startValidationParallelUploadsWithUploadUrl", + "startValidationParallelUploadsWithUploadSize", + "startValidationParallelUploadsWithDeferredLength", + "startValidationParallelBoundariesWithoutParallelUploads", + "startValidationParallelBoundariesLengthMismatch", + }, + "covered-by-generated-scenario" ), "Validate option combinations before starting runtime work.", "startOptionValidation", From 0c72eabab94c403cfc2b31522b7fff6790990601 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 23:10:16 +0200 Subject: [PATCH 17/96] Update detailed error conformance --- .../io/tus/java/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index cbcc5661..4466303d 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1047,8 +1047,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "detailedCreateResponseError", + "detailedCreateRequestError", + }, + "covered-by-generated-scenario" ), "Attach request, response, status, body, and request ID context to errors.", "detailedErrors", From 84862fb8674ef6408b3cea5f1b70e31a07d92293 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 13:41:05 +0200 Subject: [PATCH 18/96] Expose generated conformance scenarios --- .../client/GeneratedTusProtocolContract.java | 715 +++++++++++++++++- .../TestGeneratedTusProtocolContract.java | 32 + 2 files changed, 745 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 4466303d..652d718a 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -488,6 +488,7 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "creationWithUpload", + "creationWithUploadPartialChunk", }, "covered-by-generated-scenario" ), @@ -511,6 +512,7 @@ final class GeneratedTusProtocolContract { }, new String[] { "createTusUpload", + "patchTusUpload", }, new String[] { "upload-during-creation", @@ -550,6 +552,46 @@ final class GeneratedTusProtocolContract { "send-upload-body-headers", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "customRequestHeaders", + }, + "covered-by-generated-scenario" + ), + "Apply user-provided request headers to every upload request.", + "customRequestHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "apply-custom-request-headers", + "", + "Merge user-provided headers after protocol headers are prepared." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create uploads with the configured custom headers." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with the configured custom headers." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { @@ -594,10 +636,11 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "parallelUploadConcat", + "parallelUploadAbortCleanup", }, "covered-by-generated-scenario" ), - "Split one input into partial uploads and concatenate their upload URLs.", + "Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.", "parallelUploadConcat", new GeneratedTusClientFeatureFlowStep[] { new GeneratedTusClientFeatureFlowStep( @@ -630,6 +673,7 @@ final class GeneratedTusProtocolContract { "concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", + "terminate-upload", } ), new GeneratedTusClientFeature( @@ -746,6 +790,7 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "abortUpload", + "abortUploadAfterStoredUrl", }, "covered-by-generated-scenario" ), @@ -760,9 +805,12 @@ final class GeneratedTusProtocolContract { "Cancel in-flight transport work without emitting user callbacks after abort." ), }, - new String[0], + new String[] { + "terminateTusUpload", + }, new String[] { "abort-current-request", + "terminate-upload", } ), new GeneratedTusClientFeature( @@ -1024,6 +1072,7 @@ final class GeneratedTusProtocolContract { "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", + "startValidationParallelUploadsWithUploadDataDuringCreation", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch", }, @@ -1071,6 +1120,635 @@ final class GeneratedTusProtocolContract { ), }; + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = + new GeneratedTusClientConformanceScenario[] { + new GeneratedTusClientConformanceScenario( + "single-upload-lifecycle", + "success", + null, + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + }, + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "creation-with-upload", + "success", + null, + "creationWithUpload", + "creationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "creation-with-upload-partial-chunk", + "success", + null, + "creationWithUpload", + "creationWithUploadPartialChunk", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "creation-with-upload", + "success", + null, + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "upload-body-headers", + "success", + null, + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "missingInput", + "startOptionValidation", + "startValidationMissingInput", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "missingEndpointOrUploadUrl", + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "unsupportedProtocol", + "startOptionValidation", + "startValidationUnsupportedProtocol", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "retryDelaysNotArray", + "startOptionValidation", + "startValidationRetryDelaysNotArray", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "parallelUploadsWithUploadUrl", + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "parallelUploadsWithUploadSize", + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "parallelUploadsWithDeferredLength", + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "parallelUploadsWithUploadDataDuringCreation", + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "parallelBoundariesWithoutParallelUploads", + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "start-option-validation", + "error", + "parallelBoundariesLengthMismatch", + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "detailed-error", + "error", + "unexpectedCreateResponse", + "detailedErrors", + "detailedCreateResponseError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "detailed-error", + "error", + "createUploadRequestFailed", + "detailedErrors", + "detailedCreateRequestError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "upload-body-headers", + "success", + null, + "uploadBodyHeaders", + "uploadBodyHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "custom-request-headers", + "success", + null, + "customRequestHeaders", + "customRequestHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "resume-from-previous-upload", + "success", + null, + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + }, + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "relative-location-resolution", + "success", + null, + "relativeLocationResolution", + "relativeLocationResolution", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "resolve-relative-location", + }, + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "array-buffer-input", + "success", + null, + "inputSources", + "arrayBufferInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "array-buffer-view-input", + "success", + null, + "inputSources", + "arrayBufferViewInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "web-readable-stream-input", + "success", + null, + "inputSources", + "webReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-web-stream", + }, + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "node-readable-stream-input", + "success", + null, + "inputSources", + "nodeReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-stream", + }, + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "node-path-input", + "success", + null, + "inputSources", + "nodePathInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + }, + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "deferred-length-upload", + "success", + null, + "deferredLengthUpload", + "deferredLengthUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + }, + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "override-patch-method", + "success", + null, + "overridePatchMethod", + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + }, + new String[0] + ), + new GeneratedTusClientConformanceScenario( + "parallel-upload-concat", + "success", + null, + "parallelUploadConcat", + "parallelUploadConcat", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "createTusUpload", + }, + new String[] { + "concatenate-partial-uploads", + "emit-progress", + }, + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), + new GeneratedTusClientConformanceScenario( + "parallel-upload-abort-cleanup", + "aborted", + null, + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + "concatenate-partial-uploads", + }, + new String[] { + "request-abort:3", + } + ), + new GeneratedTusClientConformanceScenario( + "retry-patch-after-offset-recovery", + "success", + null, + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new String[] { + "createTusUpload", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + }, + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ), + new GeneratedTusClientConformanceScenario( + "request-lifecycle-hooks", + "success", + null, + "requestLifecycleHooks", + "requestLifecycleHooks", + new String[] { + "getTusUploadOffset", + }, + new String[] { + "run-request-hooks", + }, + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ), + new GeneratedTusClientConformanceScenario( + "abort-upload", + "aborted", + null, + "abortUpload", + "abortUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "abort-current-request", + }, + new String[] { + "request-abort:0", + } + ), + new GeneratedTusClientConformanceScenario( + "abort-upload-after-stored-url", + "aborted", + null, + "abortUpload", + "abortUploadAfterStoredUrl", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + }, + new String[] { + "request-abort:1", + } + ), + new GeneratedTusClientConformanceScenario( + "terminate-with-retry", + "terminated", + null, + "terminateUpload", + "terminateWithRetry", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "terminate-upload", + "retry-with-backoff", + }, + new String[0] + ), + }; + private GeneratedTusProtocolContract() { } @@ -1239,4 +1917,37 @@ static final class GeneratedTusClientFeatureFlowStep { this.summary = summary; } } + + /** + * Generated client conformance scenario fixture. + */ + static final class GeneratedTusClientConformanceScenario { + final String behavior; + final String completionKind; + final String completionReason; + final String featureId; + final String scenarioId; + final String[] operationIds; + final String[] primitives; + final String[] eventKeys; + + GeneratedTusClientConformanceScenario( + String behavior, + String completionKind, + String completionReason, + String featureId, + String scenarioId, + String[] operationIds, + String[] primitives, + String[] eventKeys) { + this.behavior = behavior; + this.completionKind = completionKind; + this.completionReason = completionReason; + this.featureId = featureId; + this.scenarioId = scenarioId; + this.operationIds = operationIds; + this.primitives = primitives; + this.eventKeys = eventKeys; + } + } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java index 6d57686e..ca098930 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -70,6 +70,26 @@ public void testSingleUploadLifecycleFeatureReferencesProtocolOperations() { assertContains(feature.primitives, "emit-progress"); } + /** + * Verifies generated high-level conformance scenarios expose projected event keys. + */ + @Test + public void testConformanceScenarioCarriesProjectedEventKeys() { + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature("creationWithUpload"); + GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario = + findScenario("creationWithUploadPartialChunk"); + + assertContains(feature.conformance.scenarioIds, scenario.scenarioId); + assertEquals("creation-with-upload-partial-chunk", scenario.behavior); + assertEquals("success", scenario.completionKind); + assertContains(scenario.operationIds, "createTusUpload"); + assertContains(scenario.operationIds, "patchTusUpload"); + assertContains(scenario.primitives, "upload-during-creation"); + assertContains(scenario.eventKeys, "chunk-complete:5:10:11"); + assertContains(scenario.eventKeys, "chunk-complete:1:11:11"); + } + private static GeneratedTusProtocolContract.GeneratedTusProtocolOperation findOperation( String operationId) { for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation @@ -94,6 +114,18 @@ private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeatur throw new AssertionError("Missing generated TUS client feature: " + featureId); } + private static GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario findScenario( + String scenarioId) { + for (GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario + : GeneratedTusProtocolContract.CLIENT_CONFORMANCE_SCENARIOS) { + if (scenario.scenarioId.equals(scenarioId)) { + return scenario; + } + } + + throw new AssertionError("Missing generated TUS client scenario: " + scenarioId); + } + private static boolean hasRequiredHeader( GeneratedTusProtocolContract.GeneratedTusHeaderVariant variant, String headerName) { From 9a0d85a115c22e318c166717d1155fb54c2e2bb7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 13:55:53 +0200 Subject: [PATCH 19/96] Add generated conformance event canary --- .../TestGeneratedTusConformanceEvents.java | 285 ++++++++++++++++++ .../TestGeneratedTusProtocolContract.java | 32 -- 2 files changed, 285 insertions(+), 32 deletions(-) create mode 100644 src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java new file mode 100644 index 00000000..40526792 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -0,0 +1,285 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.java.client; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * Tests generated TUS client conformance event fixtures. + */ +public class TestGeneratedTusConformanceEvents { + private static final GeneratedTusEventCanaryCase[] CASES = + new GeneratedTusEventCanaryCase[] { + new GeneratedTusEventCanaryCase( + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "creationWithUpload", + "creationWithUpload", + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "creationWithUpload", + "creationWithUploadPartialChunk", + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "relativeLocationResolution", + "relativeLocationResolution", + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "arrayBufferInput", + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "arrayBufferViewInput", + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "webReadableStreamInput", + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "nodeReadableStreamInput", + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "nodePathInput", + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "deferredLengthUpload", + "deferredLengthUpload", + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "parallelUploadConcat", + "parallelUploadConcat", + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), + new GeneratedTusEventCanaryCase( + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new String[] { + "request-abort:3", + } + ), + new GeneratedTusEventCanaryCase( + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ), + new GeneratedTusEventCanaryCase( + "requestLifecycleHooks", + "requestLifecycleHooks", + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "abortUpload", + "abortUpload", + new String[] { + "request-abort:0", + } + ), + new GeneratedTusEventCanaryCase( + "abortUpload", + "abortUploadAfterStoredUrl", + new String[] { + "request-abort:1", + } + ), + }; + + /** + * Verifies generated feature-level event keys survive in the Java fixture. + */ + @Test + public void testGeneratedScenarioEventKeys() { + for (GeneratedTusEventCanaryCase testCase : CASES) { + GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario = + findScenario(testCase.scenarioId); + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature(testCase.featureId); + + assertEquals(testCase.featureId, scenario.featureId); + assertContains(feature.conformance.scenarioIds, scenario.scenarioId); + assertArrayEquals(testCase.eventKeys, scenario.eventKeys); + } + } + + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( + String featureId) { + for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature + : GeneratedTusProtocolContract.CLIENT_FEATURES) { + if (feature.featureId.equals(featureId)) { + return feature; + } + } + + throw new AssertionError("Missing generated TUS client feature: " + featureId); + } + + private static GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario findScenario( + String scenarioId) { + for (GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario + : GeneratedTusProtocolContract.CLIENT_CONFORMANCE_SCENARIOS) { + if (scenario.scenarioId.equals(scenarioId)) { + return scenario; + } + } + + throw new AssertionError("Missing generated TUS client scenario: " + scenarioId); + } + + private static void assertContains(String[] values, String expected) { + for (String value : values) { + if (value.equals(expected)) { + return; + } + } + + throw new AssertionError("Missing generated value: " + expected); + } + + private static final class GeneratedTusEventCanaryCase { + final String featureId; + final String scenarioId; + final String[] eventKeys; + + GeneratedTusEventCanaryCase(String featureId, String scenarioId, String[] eventKeys) { + this.featureId = featureId; + this.scenarioId = scenarioId; + this.eventKeys = eventKeys; + } + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java index ca098930..6d57686e 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -70,26 +70,6 @@ public void testSingleUploadLifecycleFeatureReferencesProtocolOperations() { assertContains(feature.primitives, "emit-progress"); } - /** - * Verifies generated high-level conformance scenarios expose projected event keys. - */ - @Test - public void testConformanceScenarioCarriesProjectedEventKeys() { - GeneratedTusProtocolContract.GeneratedTusClientFeature feature = - findFeature("creationWithUpload"); - GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario = - findScenario("creationWithUploadPartialChunk"); - - assertContains(feature.conformance.scenarioIds, scenario.scenarioId); - assertEquals("creation-with-upload-partial-chunk", scenario.behavior); - assertEquals("success", scenario.completionKind); - assertContains(scenario.operationIds, "createTusUpload"); - assertContains(scenario.operationIds, "patchTusUpload"); - assertContains(scenario.primitives, "upload-during-creation"); - assertContains(scenario.eventKeys, "chunk-complete:5:10:11"); - assertContains(scenario.eventKeys, "chunk-complete:1:11:11"); - } - private static GeneratedTusProtocolContract.GeneratedTusProtocolOperation findOperation( String operationId) { for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation @@ -114,18 +94,6 @@ private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeatur throw new AssertionError("Missing generated TUS client feature: " + featureId); } - private static GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario findScenario( - String scenarioId) { - for (GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario - : GeneratedTusProtocolContract.CLIENT_CONFORMANCE_SCENARIOS) { - if (scenario.scenarioId.equals(scenarioId)) { - return scenario; - } - } - - throw new AssertionError("Missing generated TUS client scenario: " + scenarioId); - } - private static boolean hasRequiredHeader( GeneratedTusProtocolContract.GeneratedTusHeaderVariant variant, String headerName) { From 6a8c8fadb98e0539b7978f9d0f150935091af576 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 19:12:52 +0200 Subject: [PATCH 20/96] Regenerate TUS protocol fixture for lint --- .../client/GeneratedTusProtocolContract.java | 230 ++++++++++++------ 1 file changed, 156 insertions(+), 74 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 652d718a..18e9d68d 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1124,8 +1124,10 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientConformanceScenario[] { new GeneratedTusClientConformanceScenario( "single-upload-lifecycle", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "singleUploadLifecycle", "singleUploadLifecycle", new String[] { @@ -1153,8 +1155,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "creation-with-upload", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "creationWithUpload", "creationWithUpload", new String[] { @@ -1174,8 +1178,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "creation-with-upload-partial-chunk", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "creationWithUpload", "creationWithUploadPartialChunk", new String[] { @@ -1202,8 +1208,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "creation-with-upload", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "protocolVersionSelection", "ietfDraft05CreationWithUpload", new String[] { @@ -1222,8 +1230,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "upload-body-headers", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "protocolVersionSelection", "ietfDraft03ResumeWithoutKnownLength", new String[] { @@ -1244,8 +1254,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "missingInput", + new GeneratedTusClientConformanceCompletion( + "error", + "missingInput" + ), "startOptionValidation", "startValidationMissingInput", new String[0], @@ -1256,8 +1268,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "missingEndpointOrUploadUrl", + new GeneratedTusClientConformanceCompletion( + "error", + "missingEndpointOrUploadUrl" + ), "startOptionValidation", "startValidationMissingEndpointOrUploadUrl", new String[0], @@ -1268,8 +1282,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "unsupportedProtocol", + new GeneratedTusClientConformanceCompletion( + "error", + "unsupportedProtocol" + ), "startOptionValidation", "startValidationUnsupportedProtocol", new String[0], @@ -1280,8 +1296,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "retryDelaysNotArray", + new GeneratedTusClientConformanceCompletion( + "error", + "retryDelaysNotArray" + ), "startOptionValidation", "startValidationRetryDelaysNotArray", new String[0], @@ -1292,8 +1310,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "parallelUploadsWithUploadUrl", + new GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadUrl" + ), "startOptionValidation", "startValidationParallelUploadsWithUploadUrl", new String[0], @@ -1304,8 +1324,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "parallelUploadsWithUploadSize", + new GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadSize" + ), "startOptionValidation", "startValidationParallelUploadsWithUploadSize", new String[0], @@ -1316,8 +1338,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "parallelUploadsWithDeferredLength", + new GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithDeferredLength" + ), "startOptionValidation", "startValidationParallelUploadsWithDeferredLength", new String[0], @@ -1328,8 +1352,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "parallelUploadsWithUploadDataDuringCreation", + new GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadDataDuringCreation" + ), "startOptionValidation", "startValidationParallelUploadsWithUploadDataDuringCreation", new String[0], @@ -1340,8 +1366,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "parallelBoundariesWithoutParallelUploads", + new GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesWithoutParallelUploads" + ), "startOptionValidation", "startValidationParallelBoundariesWithoutParallelUploads", new String[0], @@ -1352,8 +1380,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "start-option-validation", - "error", - "parallelBoundariesLengthMismatch", + new GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesLengthMismatch" + ), "startOptionValidation", "startValidationParallelBoundariesLengthMismatch", new String[0], @@ -1364,8 +1394,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "detailed-error", - "error", - "unexpectedCreateResponse", + new GeneratedTusClientConformanceCompletion( + "error", + "unexpectedCreateResponse" + ), "detailedErrors", "detailedCreateResponseError", new String[] { @@ -1378,8 +1410,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "detailed-error", - "error", - "createUploadRequestFailed", + new GeneratedTusClientConformanceCompletion( + "error", + "createUploadRequestFailed" + ), "detailedErrors", "detailedCreateRequestError", new String[] { @@ -1392,8 +1426,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "upload-body-headers", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "uploadBodyHeaders", "uploadBodyHeaders", new String[] { @@ -1407,8 +1443,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "custom-request-headers", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "customRequestHeaders", "customRequestHeaders", new String[] { @@ -1422,8 +1460,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "resume-from-previous-upload", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "resumeUpload", "resumeFromPreviousUpload", new String[] { @@ -1450,8 +1490,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "relative-location-resolution", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "relativeLocationResolution", "relativeLocationResolution", new String[] { @@ -1472,8 +1514,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "array-buffer-input", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "inputSources", "arrayBufferInput", new String[] { @@ -1491,8 +1535,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "array-buffer-view-input", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "inputSources", "arrayBufferViewInput", new String[] { @@ -1510,8 +1556,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "web-readable-stream-input", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "inputSources", "webReadableStreamInput", new String[] { @@ -1529,8 +1577,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "node-readable-stream-input", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "inputSources", "nodeReadableStreamInput", new String[] { @@ -1548,8 +1598,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "node-path-input", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "inputSources", "nodePathInput", new String[] { @@ -1567,8 +1619,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "deferred-length-upload", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "deferredLengthUpload", "deferredLengthUpload", new String[] { @@ -1590,8 +1644,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "override-patch-method", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "overridePatchMethod", "overridePatchMethod", new String[] { @@ -1605,8 +1661,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "parallel-upload-concat", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "parallelUploadConcat", "parallelUploadConcat", new String[] { @@ -1629,8 +1687,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "parallel-upload-abort-cleanup", - "aborted", - null, + new GeneratedTusClientConformanceCompletion( + "aborted", + null + ), "parallelUploadConcat", "parallelUploadAbortCleanup", new String[] { @@ -1652,8 +1712,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "retry-patch-after-offset-recovery", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "retryOffsetRecovery", "retryPatchAfterOffsetRecovery", new String[] { @@ -1677,8 +1739,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "request-lifecycle-hooks", - "success", - null, + new GeneratedTusClientConformanceCompletion( + "success", + null + ), "requestLifecycleHooks", "requestLifecycleHooks", new String[] { @@ -1696,8 +1760,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "abort-upload", - "aborted", - null, + new GeneratedTusClientConformanceCompletion( + "aborted", + null + ), "abortUpload", "abortUpload", new String[] { @@ -1712,8 +1778,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "abort-upload-after-stored-url", - "aborted", - null, + new GeneratedTusClientConformanceCompletion( + "aborted", + null + ), "abortUpload", "abortUploadAfterStoredUrl", new String[] { @@ -1731,8 +1799,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientConformanceScenario( "terminate-with-retry", - "terminated", - null, + new GeneratedTusClientConformanceCompletion( + "terminated", + null + ), "terminateUpload", "terminateWithRetry", new String[] { @@ -1933,16 +2003,15 @@ static final class GeneratedTusClientConformanceScenario { GeneratedTusClientConformanceScenario( String behavior, - String completionKind, - String completionReason, + GeneratedTusClientConformanceCompletion completion, String featureId, String scenarioId, String[] operationIds, String[] primitives, String[] eventKeys) { this.behavior = behavior; - this.completionKind = completionKind; - this.completionReason = completionReason; + this.completionKind = completion.kind; + this.completionReason = completion.reason; this.featureId = featureId; this.scenarioId = scenarioId; this.operationIds = operationIds; @@ -1950,4 +2019,17 @@ static final class GeneratedTusClientConformanceScenario { this.eventKeys = eventKeys; } } + + /** + * Generated client conformance completion fixture. + */ + static final class GeneratedTusClientConformanceCompletion { + final String kind; + final String reason; + + GeneratedTusClientConformanceCompletion(String kind, String reason) { + this.kind = kind; + this.reason = reason; + } + } } From 1a3a0852e12c527024b5b72859dd9593f92961cb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 20:17:51 +0200 Subject: [PATCH 21/96] Add generated runtime event canary --- .../java/io/tus/java/client/TusUploader.java | 72 ++ ...eneratedTusClientConformanceScenarios.java | 714 ++++++++++++++++++ .../client/GeneratedTusProtocolContract.java | 698 +---------------- .../client/TestGeneratedTusRuntimeEvents.java | 315 ++++++++ 4 files changed, 1102 insertions(+), 697 deletions(-) create mode 100644 src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java create mode 100644 src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index d84bd8b4..8af4c462 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -21,6 +21,31 @@ * */ public class TusUploader { + /** + * Callback for upload progress events. + */ + public interface ProgressListener { + /** + * Called when upload progress changes. + * @param bytesSent Bytes accepted locally for the upload. + * @param bytesTotal Total upload size. + */ + void onProgress(long bytesSent, long bytesTotal); + } + + /** + * Callback for accepted chunk events. + */ + public interface ChunkCompleteListener { + /** + * Called after the server accepts an upload request. + * @param chunkSize Bytes accepted by the completed request. + * @param bytesAccepted Total bytes accepted by the server. + * @param bytesTotal Total upload size. + */ + void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal); + } + private URL uploadURL; private Proxy proxy; private TusInputStream input; @@ -30,6 +55,10 @@ public class TusUploader { private byte[] buffer; private int requestPayloadSize = 10 * 1024 * 1024; private int bytesRemainingForRequest; + private long requestStartOffset; + private boolean requestProgressStarted; + private ProgressListener progressListener; + private ChunkCompleteListener chunkCompleteListener; private HttpURLConnection connection; private OutputStream output; @@ -65,6 +94,8 @@ private void openConnection() throws IOException, ProtocolException { } bytesRemainingForRequest = requestPayloadSize; + requestStartOffset = offset; + requestProgressStarted = false; input.mark(requestPayloadSize); if (proxy != null) { @@ -168,6 +199,24 @@ public int getRequestPayloadSize() { return requestPayloadSize; } + /** + * Set the listener used for upload progress events. + * + * @param listener Progress listener or null to disable events. + */ + public void setProgressListener(ProgressListener listener) { + progressListener = listener; + } + + /** + * Set the listener used for accepted chunk events. + * + * @param listener Chunk-complete listener or null to disable events. + */ + public void setChunkCompleteListener(ChunkCompleteListener listener) { + chunkCompleteListener = listener; + } + /** * Upload a part of the file by reading a chunk from the InputStream and writing * it to the HTTP request's body. If the number of available bytes is lower than the chunk's @@ -184,6 +233,7 @@ public int getRequestPayloadSize() { */ public int uploadChunk() throws IOException, ProtocolException { openConnection(); + notifyProgressAtRequestStart(); int bytesToRead = Math.min(getChunkSize(), bytesRemainingForRequest); @@ -201,6 +251,7 @@ public int uploadChunk() throws IOException, ProtocolException { offset += bytesRead; bytesRemainingForRequest -= bytesRead; + notifyProgress(offset); if (bytesRemainingForRequest <= 0) { finishConnection(); @@ -358,7 +409,28 @@ private void finishConnection() throws ProtocolException, IOException { connection); } + notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); connection = null; + requestProgressStarted = false; + } + } + + private void notifyProgressAtRequestStart() { + if (!requestProgressStarted) { + notifyProgress(offset); + requestProgressStarted = true; + } + } + + private void notifyProgress(long bytesSent) { + if (progressListener != null) { + progressListener.onProgress(bytesSent, upload.getSize()); + } + } + + private void notifyChunkComplete(long chunkSize, long bytesAccepted) { + if (chunkCompleteListener != null) { + chunkCompleteListener.onChunkComplete(chunkSize, bytesAccepted, upload.getSize()); } } diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java new file mode 100644 index 00000000..5775ae46 --- /dev/null +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -0,0 +1,714 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.java.client; + +/** + * Generated TUS client conformance scenario fixture used by tests. + */ +final class GeneratedTusClientConformanceScenarios { + static final GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] { + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "single-upload-lifecycle", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + }, + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "creationWithUpload", + "creationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload-partial-chunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "creationWithUpload", + "creationWithUploadPartialChunk", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "missingInput" + ), + "startOptionValidation", + "startValidationMissingInput", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "missingEndpointOrUploadUrl" + ), + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "unsupportedProtocol" + ), + "startOptionValidation", + "startValidationUnsupportedProtocol", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "retryDelaysNotArray" + ), + "startOptionValidation", + "startValidationRetryDelaysNotArray", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadUrl" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadSize" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithDeferredLength" + ), + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadDataDuringCreation" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesWithoutParallelUploads" + ), + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesLengthMismatch" + ), + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "detailed-error", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "unexpectedCreateResponse" + ), + "detailedErrors", + "detailedCreateResponseError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "detailed-error", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "createUploadRequestFailed" + ), + "detailedErrors", + "detailedCreateRequestError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "uploadBodyHeaders", + "uploadBodyHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "custom-request-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "customRequestHeaders", + "customRequestHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "resume-from-previous-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + }, + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "relative-location-resolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "relativeLocationResolution", + "relativeLocationResolution", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "resolve-relative-location", + }, + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "array-buffer-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "arrayBufferInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "array-buffer-view-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "arrayBufferViewInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "web-readable-stream-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "webReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-web-stream", + }, + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "node-readable-stream-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "nodeReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-stream", + }, + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "node-path-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "nodePathInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + }, + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "deferred-length-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "deferredLengthUpload", + "deferredLengthUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + }, + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "override-patch-method", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "overridePatchMethod", + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "parallel-upload-concat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "parallelUploadConcat", + "parallelUploadConcat", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "createTusUpload", + }, + new String[] { + "concatenate-partial-uploads", + "emit-progress", + }, + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "parallel-upload-abort-cleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + "concatenate-partial-uploads", + }, + new String[] { + "request-abort:3", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "retry-patch-after-offset-recovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new String[] { + "createTusUpload", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + }, + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "request-lifecycle-hooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "requestLifecycleHooks", + "requestLifecycleHooks", + new String[] { + "getTusUploadOffset", + }, + new String[] { + "run-request-hooks", + }, + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "abort-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "abortUpload", + "abortUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "abort-current-request", + }, + new String[] { + "request-abort:0", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "abort-upload-after-stored-url", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "abortUpload", + "abortUploadAfterStoredUrl", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + }, + new String[] { + "request-abort:1", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "terminate-with-retry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "terminated", + null + ), + "terminateUpload", + "terminateWithRetry", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "terminate-upload", + "retry-with-backoff", + }, + new String[0] + ), + }; + + private GeneratedTusClientConformanceScenarios() { + } +} diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 18e9d68d..e4108fbf 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,703 +1121,7 @@ final class GeneratedTusProtocolContract { }; static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = - new GeneratedTusClientConformanceScenario[] { - new GeneratedTusClientConformanceScenario( - "single-upload-lifecycle", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "singleUploadLifecycle", - "singleUploadLifecycle", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "open-input-source", - "fingerprint-input", - "store-resume-url", - "retry-with-backoff", - "emit-progress", - "abort-current-request", - }, - new String[] { - "fingerprint:contract-single-fingerprint", - "upload-url-available", - "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "creation-with-upload", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "creationWithUpload", - "creationWithUpload", - new String[] { - "createTusUpload", - }, - new String[] { - "upload-during-creation", - "emit-progress", - }, - new String[] { - "progress:0:11", - "progress:11:11", - "upload-url-available", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "creation-with-upload-partial-chunk", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "creationWithUpload", - "creationWithUploadPartialChunk", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "upload-during-creation", - "emit-progress", - }, - new String[] { - "progress:0:11", - "progress:5:11", - "upload-url-available", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", - "progress:10:11", - "progress:11:11", - "chunk-complete:1:11:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "creation-with-upload", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "protocolVersionSelection", - "ietfDraft05CreationWithUpload", - new String[] { - "createTusUpload", - }, - new String[] { - "select-client-protocol", - }, - new String[] { - "progress:0:11", - "progress:11:11", - "upload-url-available", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "upload-body-headers", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "protocolVersionSelection", - "ietfDraft03ResumeWithoutKnownLength", - new String[] { - "getTusUploadOffset", - "patchTusUpload", - }, - new String[] { - "select-client-protocol", - }, - new String[] { - "upload-url-available", - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "missingInput" - ), - "startOptionValidation", - "startValidationMissingInput", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "missingEndpointOrUploadUrl" - ), - "startOptionValidation", - "startValidationMissingEndpointOrUploadUrl", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "unsupportedProtocol" - ), - "startOptionValidation", - "startValidationUnsupportedProtocol", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "retryDelaysNotArray" - ), - "startOptionValidation", - "startValidationRetryDelaysNotArray", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithUploadUrl" - ), - "startOptionValidation", - "startValidationParallelUploadsWithUploadUrl", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithUploadSize" - ), - "startOptionValidation", - "startValidationParallelUploadsWithUploadSize", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithDeferredLength" - ), - "startOptionValidation", - "startValidationParallelUploadsWithDeferredLength", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithUploadDataDuringCreation" - ), - "startOptionValidation", - "startValidationParallelUploadsWithUploadDataDuringCreation", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "parallelBoundariesWithoutParallelUploads" - ), - "startOptionValidation", - "startValidationParallelBoundariesWithoutParallelUploads", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusClientConformanceCompletion( - "error", - "parallelBoundariesLengthMismatch" - ), - "startOptionValidation", - "startValidationParallelBoundariesLengthMismatch", - new String[0], - new String[] { - "validate-start-options", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "detailed-error", - new GeneratedTusClientConformanceCompletion( - "error", - "unexpectedCreateResponse" - ), - "detailedErrors", - "detailedCreateResponseError", - new String[] { - "createTusUpload", - }, - new String[] { - "report-detailed-errors", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "detailed-error", - new GeneratedTusClientConformanceCompletion( - "error", - "createUploadRequestFailed" - ), - "detailedErrors", - "detailedCreateRequestError", - new String[] { - "createTusUpload", - }, - new String[] { - "report-detailed-errors", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "upload-body-headers", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "uploadBodyHeaders", - "uploadBodyHeaders", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "send-upload-body-headers", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "custom-request-headers", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "customRequestHeaders", - "customRequestHeaders", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "apply-custom-request-headers", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "resume-from-previous-upload", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "resumeUpload", - "resumeFromPreviousUpload", - new String[] { - "getTusUploadOffset", - "patchTusUpload", - }, - new String[] { - "fingerprint-input", - "resume-from-previous-upload", - "store-resume-url", - }, - new String[] { - "fingerprint:contract-resume-fingerprint", - "url-storage-find:contract-resume-fingerprint:1", - "fingerprint:contract-resume-fingerprint", - "upload-url-available", - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - "url-storage-remove:tus::contract-resume-fingerprint::1337", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "relative-location-resolution", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "relativeLocationResolution", - "relativeLocationResolution", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "resolve-relative-location", - }, - new String[] { - "upload-url-available", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "array-buffer-input", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "inputSources", - "arrayBufferInput", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "read-browser-file", - }, - new String[] { - "source-open:array-buffer:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "array-buffer-view-input", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "inputSources", - "arrayBufferViewInput", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "read-browser-file", - }, - new String[] { - "source-open:array-buffer-view:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "web-readable-stream-input", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "inputSources", - "webReadableStreamInput", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "read-web-stream", - }, - new String[] { - "source-open:web-readable-stream:null", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "node-readable-stream-input", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "inputSources", - "nodeReadableStreamInput", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "read-node-stream", - }, - new String[] { - "source-open:node-readable-stream:null", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "node-path-input", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "inputSources", - "nodePathInput", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "read-node-file", - }, - new String[] { - "source-open:node-path-reference:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "deferred-length-upload", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "deferredLengthUpload", - "deferredLengthUpload", - new String[] { - "createTusUpload", - "patchTusUpload", - }, - new String[] { - "defer-upload-length", - "emit-progress", - }, - new String[] { - "upload-url-available", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "override-patch-method", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "overridePatchMethod", - "overridePatchMethod", - new String[] { - "getTusUploadOffset", - "patchTusUpload", - }, - new String[] { - "override-patch-method", - }, - new String[0] - ), - new GeneratedTusClientConformanceScenario( - "parallel-upload-concat", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "parallelUploadConcat", - "parallelUploadConcat", - new String[] { - "createTusUpload", - "createTusUpload", - "patchTusUpload", - "patchTusUpload", - "createTusUpload", - }, - new String[] { - "concatenate-partial-uploads", - "emit-progress", - }, - new String[] { - "progress:5:11", - "chunk-complete:5:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - } - ), - new GeneratedTusClientConformanceScenario( - "parallel-upload-abort-cleanup", - new GeneratedTusClientConformanceCompletion( - "aborted", - null - ), - "parallelUploadConcat", - "parallelUploadAbortCleanup", - new String[] { - "createTusUpload", - "createTusUpload", - "patchTusUpload", - "patchTusUpload", - "terminateTusUpload", - "terminateTusUpload", - }, - new String[] { - "abort-current-request", - "terminate-upload", - "concatenate-partial-uploads", - }, - new String[] { - "request-abort:3", - } - ), - new GeneratedTusClientConformanceScenario( - "retry-patch-after-offset-recovery", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "retryOffsetRecovery", - "retryPatchAfterOffsetRecovery", - new String[] { - "createTusUpload", - "patchTusUpload", - "getTusUploadOffset", - "patchTusUpload", - "getTusUploadOffset", - "patchTusUpload", - }, - new String[] { - "retry-with-backoff", - "recover-offset-after-error", - }, - new String[] { - "should-retry:0:true", - "retry-schedule:0", - "should-retry:0:true", - "retry-schedule:0", - } - ), - new GeneratedTusClientConformanceScenario( - "request-lifecycle-hooks", - new GeneratedTusClientConformanceCompletion( - "success", - null - ), - "requestLifecycleHooks", - "requestLifecycleHooks", - new String[] { - "getTusUploadOffset", - }, - new String[] { - "run-request-hooks", - }, - new String[] { - "before-request:0", - "after-response:0", - "success", - "source-close", - } - ), - new GeneratedTusClientConformanceScenario( - "abort-upload", - new GeneratedTusClientConformanceCompletion( - "aborted", - null - ), - "abortUpload", - "abortUpload", - new String[] { - "createTusUpload", - }, - new String[] { - "abort-current-request", - }, - new String[] { - "request-abort:0", - } - ), - new GeneratedTusClientConformanceScenario( - "abort-upload-after-stored-url", - new GeneratedTusClientConformanceCompletion( - "aborted", - null - ), - "abortUpload", - "abortUploadAfterStoredUrl", - new String[] { - "createTusUpload", - "patchTusUpload", - "terminateTusUpload", - }, - new String[] { - "abort-current-request", - "terminate-upload", - }, - new String[] { - "request-abort:1", - } - ), - new GeneratedTusClientConformanceScenario( - "terminate-with-retry", - new GeneratedTusClientConformanceCompletion( - "terminated", - null - ), - "terminateUpload", - "terminateWithRetry", - new String[] { - "createTusUpload", - "patchTusUpload", - "terminateTusUpload", - "terminateTusUpload", - }, - new String[] { - "terminate-upload", - "retry-with-backoff", - }, - new String[0] - ), - }; + GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; private GeneratedTusProtocolContract() { } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java new file mode 100644 index 00000000..41927ba2 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -0,0 +1,315 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.java.client; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertArrayEquals; + +/** + * Tests generated TUS client runtime event fixtures against the real uploader. + */ +public class TestGeneratedTusRuntimeEvents extends MockServerProvider { + private static final GeneratedTusRuntimeEventCase[] CASES = + new GeneratedTusRuntimeEventCase[] { + new GeneratedTusRuntimeEventCase( + "singleUploadLifecycle", + new GeneratedTusRuntimeEventInput( + "hello world", + "generated-contract", + "absolute", + false, + 11, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/generated-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + new GeneratedTusRuntimeEventCase( + "relativeLocationResolution", + new GeneratedTusRuntimeEventInput( + "hello world", + "relative-contract", + "relative", + true, + 11, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "relative-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + }; + + /** + * Verifies the sync uploader emits generated progress and chunk-complete events. + */ + @Test + public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Exception { + for (GeneratedTusRuntimeEventCase testCase : CASES) { + mockServer.reset(); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setUploadCreationURL(endpointUrlFor(testCase)); + + registerResponses(testCase); + + TusUploader uploader = client.createUpload(uploadFor(testCase)); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + events.add("progress:" + bytesSent + ":" + bytesTotal); + } + }); + uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add("chunk-complete:" + chunkSize + ":" + bytesAccepted + ":" + bytesTotal); + } + }); + + while (uploader.uploadChunk() > -1) { + } + uploader.finish(); + + assertArrayEquals( + testCase.scenarioId, + testCase.eventKeys, + events.toArray(new String[events.size()])); + } + } + + private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { + byte[] content = testCase.input.content.getBytes(StandardCharsets.UTF_8); + TusUpload upload = new TusUpload(); + upload.setSize(content.length); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setMetadata(metadataFor(testCase.input.metadata)); + return upload; + } + + private Map metadataFor(GeneratedTusRuntimeEventMetadata[] metadata) { + Map result = new LinkedHashMap(); + for (GeneratedTusRuntimeEventMetadata entry : metadata) { + result.put(entry.name, entry.value); + } + return result; + } + + private void registerResponses(GeneratedTusRuntimeEventCase testCase) throws Exception { + for (GeneratedTusRuntimeEventRequest request : testCase.requests) { + HttpRequest httpRequest = new HttpRequest() + .withPath(pathFor(testCase, request)); + if (!"upload".equals(request.url)) { + httpRequest.withMethod(request.method); + } + + mockServer.when(httpRequest).respond(responseFor(testCase, request)); + } + } + + private String pathFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request) throws Exception { + if ("endpoint".equals(request.url)) { + return endpointUrlFor(testCase).getPath(); + } + + return uploadUrlFor(testCase).getPath(); + } + + private HttpResponse responseFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request) throws Exception { + HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); + for (GeneratedTusRuntimeEventHeader header : request.headers) { + response.withHeader(header.name, headerValueFor(testCase, header)); + } + return response; + } + + private String headerValueFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventHeader header) throws Exception { + if (!"Location".equals(header.name)) { + return header.value; + } + + if ("relative".equals(testCase.input.locationHeaderKind)) { + return testCase.input.uploadPath; + } + + return uploadUrlFor(testCase).toString(); + } + + private URL uploadUrlFor(GeneratedTusRuntimeEventCase testCase) throws Exception { + return new URL(mockServerURL.toString() + "/" + testCase.input.uploadPath); + } + + private URL endpointUrlFor(GeneratedTusRuntimeEventCase testCase) throws Exception { + if (testCase.input.endpointHasTrailingSlash) { + return new URL(mockServerURL.toString() + "/"); + } + + return mockServerURL; + } + + private static final class GeneratedTusRuntimeEventCase { + final String scenarioId; + final GeneratedTusRuntimeEventInput input; + final GeneratedTusRuntimeEventRequest[] requests; + final String[] eventKeys; + + GeneratedTusRuntimeEventCase( + String scenarioId, + GeneratedTusRuntimeEventInput input, + GeneratedTusRuntimeEventRequest[] requests, + String[] eventKeys) { + this.scenarioId = scenarioId; + this.input = input; + this.requests = requests; + this.eventKeys = eventKeys; + } + } + + private static final class GeneratedTusRuntimeEventInput { + final String content; + final String uploadPath; + final String locationHeaderKind; + final boolean endpointHasTrailingSlash; + final int chunkSize; + final GeneratedTusRuntimeEventMetadata[] metadata; + + GeneratedTusRuntimeEventInput( + String content, + String uploadPath, + String locationHeaderKind, + boolean endpointHasTrailingSlash, + int chunkSize, + GeneratedTusRuntimeEventMetadata[] metadata) { + this.content = content; + this.uploadPath = uploadPath; + this.locationHeaderKind = locationHeaderKind; + this.endpointHasTrailingSlash = endpointHasTrailingSlash; + this.chunkSize = chunkSize; + this.metadata = metadata; + } + } + + private static final class GeneratedTusRuntimeEventRequest { + final String method; + final String url; + final int statusCode; + final GeneratedTusRuntimeEventHeader[] headers; + + GeneratedTusRuntimeEventRequest( + String method, + String url, + int statusCode, + GeneratedTusRuntimeEventHeader[] headers) { + this.method = method; + this.url = url; + this.statusCode = statusCode; + this.headers = headers; + } + } + + private static final class GeneratedTusRuntimeEventHeader { + final String name; + final String value; + + GeneratedTusRuntimeEventHeader(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusRuntimeEventMetadata { + final String name; + final String value; + + GeneratedTusRuntimeEventMetadata(String name, String value) { + this.name = name; + this.value = value; + } + } +} From e892ed197f1deceaa986409fd2bb5157da9adf7a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 20:27:30 +0200 Subject: [PATCH 22/96] Cover generated resume runtime events --- .../client/TestGeneratedTusRuntimeEvents.java | 136 +++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 41927ba2..8897a811 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -19,6 +19,8 @@ import org.mockserver.model.HttpResponse; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; /** * Tests generated TUS client runtime event fixtures against the real uploader. @@ -34,6 +36,8 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "absolute", false, 11, + null, + false, new GeneratedTusRuntimeEventMetadata[] { new GeneratedTusRuntimeEventMetadata( "filename", @@ -71,6 +75,52 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "chunk-complete:11:11:11", } ), + new GeneratedTusRuntimeEventCase( + "resumeFromPreviousUpload", + new GeneratedTusRuntimeEventInput( + "hello world", + "resume-contract", + "stored", + false, + 6, + "contract-resume-fingerprint", + true, + new GeneratedTusRuntimeEventMetadata[0] + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "HEAD", + "upload", + 200, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), new GeneratedTusRuntimeEventCase( "relativeLocationResolution", new GeneratedTusRuntimeEventInput( @@ -79,6 +129,8 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "relative", true, 11, + null, + false, new GeneratedTusRuntimeEventMetadata[] { new GeneratedTusRuntimeEventMetadata( "filename", @@ -129,10 +181,17 @@ public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Except final List events = new ArrayList(); TusClient client = new TusClient(); client.setUploadCreationURL(endpointUrlFor(testCase)); + GeneratedTusRuntimeEventUrlStore urlStore = urlStoreFor(testCase); + if (urlStore != null) { + client.enableResuming(urlStore); + } + if (testCase.input.removeFingerprintOnSuccess) { + client.enableRemoveFingerprintOnSuccess(); + } registerResponses(testCase); - TusUploader uploader = client.createUpload(uploadFor(testCase)); + TusUploader uploader = uploaderFor(client, testCase); uploader.setChunkSize(testCase.input.chunkSize); uploader.setProgressListener(new TusUploader.ProgressListener() { @Override @@ -155,7 +214,17 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) testCase.scenarioId, testCase.eventKeys, events.toArray(new String[events.size()])); + assertStoredUploadState(testCase, urlStore); + } + } + + private TusUploader uploaderFor(TusClient client, GeneratedTusRuntimeEventCase testCase) + throws Exception { + if (testCase.input.fingerprint != null) { + return client.resumeUpload(uploadFor(testCase)); } + + return client.createUpload(uploadFor(testCase)); } private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { @@ -164,6 +233,9 @@ private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { upload.setSize(content.length); upload.setInputStream(new ByteArrayInputStream(content)); upload.setMetadata(metadataFor(testCase.input.metadata)); + if (testCase.input.fingerprint != null) { + upload.setFingerprint(testCase.input.fingerprint); + } return upload; } @@ -179,7 +251,7 @@ private void registerResponses(GeneratedTusRuntimeEventCase testCase) throws Exc for (GeneratedTusRuntimeEventRequest request : testCase.requests) { HttpRequest httpRequest = new HttpRequest() .withPath(pathFor(testCase, request)); - if (!"upload".equals(request.url)) { + if (!"upload".equals(request.url) || "HEAD".equals(request.method)) { httpRequest.withMethod(request.method); } @@ -233,6 +305,41 @@ private URL endpointUrlFor(GeneratedTusRuntimeEventCase testCase) throws Excepti return mockServerURL; } + private GeneratedTusRuntimeEventUrlStore urlStoreFor( + GeneratedTusRuntimeEventCase testCase) throws Exception { + if (testCase.input.fingerprint == null) { + return null; + } + + GeneratedTusRuntimeEventUrlStore store = new GeneratedTusRuntimeEventUrlStore(); + store.set(testCase.input.fingerprint, uploadUrlFor(testCase)); + return store; + } + + private void assertStoredUploadState( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventUrlStore urlStore) { + if (urlStore == null) { + return; + } + + URL storedUrl = urlStore.get(testCase.input.fingerprint); + if (testCase.input.removeFingerprintOnSuccess) { + assertNull(testCase.scenarioId, storedUrl); + return; + } + + assertEquals(testCase.scenarioId, uploadUrlForUnchecked(testCase), storedUrl); + } + + private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { + try { + return uploadUrlFor(testCase); + } catch (Exception error) { + throw new AssertionError(error); + } + } + private static final class GeneratedTusRuntimeEventCase { final String scenarioId; final GeneratedTusRuntimeEventInput input; @@ -257,6 +364,8 @@ private static final class GeneratedTusRuntimeEventInput { final String locationHeaderKind; final boolean endpointHasTrailingSlash; final int chunkSize; + final String fingerprint; + final boolean removeFingerprintOnSuccess; final GeneratedTusRuntimeEventMetadata[] metadata; GeneratedTusRuntimeEventInput( @@ -265,12 +374,16 @@ private static final class GeneratedTusRuntimeEventInput { String locationHeaderKind, boolean endpointHasTrailingSlash, int chunkSize, + String fingerprint, + boolean removeFingerprintOnSuccess, GeneratedTusRuntimeEventMetadata[] metadata) { this.content = content; this.uploadPath = uploadPath; this.locationHeaderKind = locationHeaderKind; this.endpointHasTrailingSlash = endpointHasTrailingSlash; this.chunkSize = chunkSize; + this.fingerprint = fingerprint; + this.removeFingerprintOnSuccess = removeFingerprintOnSuccess; this.metadata = metadata; } } @@ -312,4 +425,23 @@ private static final class GeneratedTusRuntimeEventMetadata { this.value = value; } } + + private static final class GeneratedTusRuntimeEventUrlStore implements TusURLStore { + private final Map values = new LinkedHashMap(); + + @Override + public URL get(String fingerprint) { + return values.get(fingerprint); + } + + @Override + public void set(String fingerprint, URL url) { + values.put(fingerprint, url); + } + + @Override + public void remove(String fingerprint) { + values.remove(fingerprint); + } + } } From 420dea9ed968fe2224d5d131a3ca436317fb2785 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 20:44:03 +0200 Subject: [PATCH 23/96] Keep generated runtime canary lint-clean --- .../client/TestGeneratedTusRuntimeEvents.java | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 8897a811..768b6139 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -37,7 +37,6 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { false, 11, null, - false, new GeneratedTusRuntimeEventMetadata[] { new GeneratedTusRuntimeEventMetadata( "filename", @@ -83,8 +82,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "stored", false, 6, - "contract-resume-fingerprint", - true, + new GeneratedTusRuntimeEventStoredUpload( + "contract-resume-fingerprint", + true + ), new GeneratedTusRuntimeEventMetadata[0] ), new GeneratedTusRuntimeEventRequest[] { @@ -130,7 +131,6 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { true, 11, null, - false, new GeneratedTusRuntimeEventMetadata[] { new GeneratedTusRuntimeEventMetadata( "filename", @@ -185,7 +185,9 @@ public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Except if (urlStore != null) { client.enableResuming(urlStore); } - if (testCase.input.removeFingerprintOnSuccess) { + if ( + testCase.input.storedUpload != null + && testCase.input.storedUpload.removeFingerprintOnSuccess) { client.enableRemoveFingerprintOnSuccess(); } @@ -207,6 +209,7 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) }); while (uploader.uploadChunk() > -1) { + continue; } uploader.finish(); @@ -220,7 +223,7 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) private TusUploader uploaderFor(TusClient client, GeneratedTusRuntimeEventCase testCase) throws Exception { - if (testCase.input.fingerprint != null) { + if (testCase.input.storedUpload != null) { return client.resumeUpload(uploadFor(testCase)); } @@ -233,8 +236,8 @@ private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { upload.setSize(content.length); upload.setInputStream(new ByteArrayInputStream(content)); upload.setMetadata(metadataFor(testCase.input.metadata)); - if (testCase.input.fingerprint != null) { - upload.setFingerprint(testCase.input.fingerprint); + if (testCase.input.storedUpload != null) { + upload.setFingerprint(testCase.input.storedUpload.fingerprint); } return upload; } @@ -307,12 +310,12 @@ private URL endpointUrlFor(GeneratedTusRuntimeEventCase testCase) throws Excepti private GeneratedTusRuntimeEventUrlStore urlStoreFor( GeneratedTusRuntimeEventCase testCase) throws Exception { - if (testCase.input.fingerprint == null) { + if (testCase.input.storedUpload == null) { return null; } GeneratedTusRuntimeEventUrlStore store = new GeneratedTusRuntimeEventUrlStore(); - store.set(testCase.input.fingerprint, uploadUrlFor(testCase)); + store.set(testCase.input.storedUpload.fingerprint, uploadUrlFor(testCase)); return store; } @@ -323,8 +326,8 @@ private void assertStoredUploadState( return; } - URL storedUrl = urlStore.get(testCase.input.fingerprint); - if (testCase.input.removeFingerprintOnSuccess) { + URL storedUrl = urlStore.get(testCase.input.storedUpload.fingerprint); + if (testCase.input.storedUpload.removeFingerprintOnSuccess) { assertNull(testCase.scenarioId, storedUrl); return; } @@ -364,8 +367,7 @@ private static final class GeneratedTusRuntimeEventInput { final String locationHeaderKind; final boolean endpointHasTrailingSlash; final int chunkSize; - final String fingerprint; - final boolean removeFingerprintOnSuccess; + final GeneratedTusRuntimeEventStoredUpload storedUpload; final GeneratedTusRuntimeEventMetadata[] metadata; GeneratedTusRuntimeEventInput( @@ -374,17 +376,27 @@ private static final class GeneratedTusRuntimeEventInput { String locationHeaderKind, boolean endpointHasTrailingSlash, int chunkSize, - String fingerprint, - boolean removeFingerprintOnSuccess, + GeneratedTusRuntimeEventStoredUpload storedUpload, GeneratedTusRuntimeEventMetadata[] metadata) { this.content = content; this.uploadPath = uploadPath; this.locationHeaderKind = locationHeaderKind; this.endpointHasTrailingSlash = endpointHasTrailingSlash; this.chunkSize = chunkSize; + this.storedUpload = storedUpload; + this.metadata = metadata; + } + } + + private static final class GeneratedTusRuntimeEventStoredUpload { + final String fingerprint; + final boolean removeFingerprintOnSuccess; + + GeneratedTusRuntimeEventStoredUpload( + String fingerprint, + boolean removeFingerprintOnSuccess) { this.fingerprint = fingerprint; this.removeFingerprintOnSuccess = removeFingerprintOnSuccess; - this.metadata = metadata; } } From 40143841038f51a1dc56aa5db5e5b000e6d85ff3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 22:12:19 +0200 Subject: [PATCH 24/96] Support deferred length uploads --- .../java/io/tus/java/client/TusClient.java | 6 +- .../java/io/tus/java/client/TusUpload.java | 21 ++++ .../java/io/tus/java/client/TusUploader.java | 12 ++ .../client/GeneratedTusProtocolContract.java | 1 + .../client/TestGeneratedTusRuntimeEvents.java | 115 +++++++++++++++++- .../io/tus/java/client/TestTusClient.java | 34 ++++++ .../io/tus/java/client/TestTusUploader.java | 36 ++++++ 7 files changed, 220 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 4b56a474..d4df183f 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -203,7 +203,11 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept connection.setRequestProperty("Upload-Metadata", encodedMetadata); } - connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize())); + if (upload.isUploadLengthDeferred()) { + connection.addRequestProperty("Upload-Defer-Length", "1"); + } else { + connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize())); + } connection.connect(); int responseCode = connection.getResponseCode(); diff --git a/src/main/java/io/tus/java/client/TusUpload.java b/src/main/java/io/tus/java/client/TusUpload.java index 5559af59..ecd63732 100644 --- a/src/main/java/io/tus/java/client/TusUpload.java +++ b/src/main/java/io/tus/java/client/TusUpload.java @@ -21,6 +21,7 @@ public class TusUpload { private TusInputStream tusInputStream; private String fingerprint; private Map metadata; + private boolean uploadLengthDeferred; /** * Create a new TusUpload object. @@ -62,6 +63,26 @@ public void setSize(long size) { this.size = size; } + /** + * Returns whether upload creation should defer declaring the upload length. + * @return True if the Upload-Defer-Length creation header should be used. + */ + public boolean isUploadLengthDeferred() { + return uploadLengthDeferred; + } + + /** + * Set whether upload creation should defer declaring the upload length. + * + * When enabled, the upload is created with Upload-Defer-Length and the uploader declares + * Upload-Length on the first PATCH request. + * + * @param uploadLengthDeferred True to use deferred upload length creation. + */ + public void setUploadLengthDeferred(boolean uploadLengthDeferred) { + this.uploadLengthDeferred = uploadLengthDeferred; + } + /** * Returns the file specific fingerprint. * @return Fingerprint as String. diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index 8af4c462..d3b97d78 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -56,7 +56,9 @@ public interface ChunkCompleteListener { private int requestPayloadSize = 10 * 1024 * 1024; private int bytesRemainingForRequest; private long requestStartOffset; + private boolean requestDeclaresUploadLength; private boolean requestProgressStarted; + private boolean uploadLengthDeclared; private ProgressListener progressListener; private ChunkCompleteListener chunkCompleteListener; @@ -81,6 +83,7 @@ public TusUploader(TusClient client, TusUpload upload, URL uploadURL, TusInputSt this.offset = offset; this.client = client; this.upload = upload; + uploadLengthDeclared = !upload.isUploadLengthDeferred(); input.seekTo(offset); @@ -94,6 +97,7 @@ private void openConnection() throws IOException, ProtocolException { } bytesRemainingForRequest = requestPayloadSize; + requestDeclaresUploadLength = false; requestStartOffset = offset; requestProgressStarted = false; input.mark(requestPayloadSize); @@ -105,6 +109,10 @@ private void openConnection() throws IOException, ProtocolException { } client.prepareConnection(connection); connection.setRequestProperty("Upload-Offset", Long.toString(offset)); + if (!uploadLengthDeclared) { + connection.setRequestProperty("Upload-Length", Long.toString(upload.getSize())); + requestDeclaresUploadLength = true; + } connection.setRequestProperty("Content-Type", "application/offset+octet-stream"); connection.setRequestProperty("Expect", "100-continue"); @@ -409,8 +417,12 @@ private void finishConnection() throws ProtocolException, IOException { connection); } + if (requestDeclaresUploadLength) { + uploadLengthDeclared = true; + } notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); connection = null; + requestDeclaresUploadLength = false; requestProgressStarted = false; } } diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index e4108fbf..13c1127b 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -670,6 +670,7 @@ final class GeneratedTusProtocolContract { "patchTusUpload", }, new String[] { + "abort-current-request", "concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 768b6139..284d1f83 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -30,6 +30,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase[] { new GeneratedTusRuntimeEventCase( "singleUploadLifecycle", + false, new GeneratedTusRuntimeEventInput( "hello world", "generated-contract", @@ -49,6 +50,12 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "POST", "endpoint", 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", @@ -60,6 +67,12 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "PATCH", "upload", 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", @@ -76,6 +89,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "resumeFromPreviousUpload", + false, new GeneratedTusRuntimeEventInput( "hello world", "resume-contract", @@ -93,6 +107,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "HEAD", "upload", 200, + new GeneratedTusRuntimeEventHeader[0], new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Length", @@ -108,6 +123,12 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "PATCH", "upload", 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", @@ -124,6 +145,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "relativeLocationResolution", + false, new GeneratedTusRuntimeEventInput( "hello world", "relative-contract", @@ -143,6 +165,12 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "POST", "endpoint", 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", @@ -154,6 +182,75 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "PATCH", "upload", 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + new GeneratedTusRuntimeEventCase( + "deferredLengthUpload", + true, + new GeneratedTusRuntimeEventInput( + "hello world", + "deferred-contract", + "absolute", + false, + 100, + null, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Defer-Length", + "1" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/deferred-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", @@ -239,6 +336,7 @@ private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { if (testCase.input.storedUpload != null) { upload.setFingerprint(testCase.input.storedUpload.fingerprint); } + upload.setUploadLengthDeferred(testCase.uploadLengthDeferred); return upload; } @@ -257,6 +355,9 @@ private void registerResponses(GeneratedTusRuntimeEventCase testCase) throws Exc if (!"upload".equals(request.url) || "HEAD".equals(request.method)) { httpRequest.withMethod(request.method); } + for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { + httpRequest.withHeader(header.name, header.value); + } mockServer.when(httpRequest).respond(responseFor(testCase, request)); } @@ -276,7 +377,7 @@ private HttpResponse responseFor( GeneratedTusRuntimeEventCase testCase, GeneratedTusRuntimeEventRequest request) throws Exception { HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); - for (GeneratedTusRuntimeEventHeader header : request.headers) { + for (GeneratedTusRuntimeEventHeader header : request.responseHeaders) { response.withHeader(header.name, headerValueFor(testCase, header)); } return response; @@ -345,16 +446,19 @@ private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { private static final class GeneratedTusRuntimeEventCase { final String scenarioId; + final boolean uploadLengthDeferred; final GeneratedTusRuntimeEventInput input; final GeneratedTusRuntimeEventRequest[] requests; final String[] eventKeys; GeneratedTusRuntimeEventCase( String scenarioId, + boolean uploadLengthDeferred, GeneratedTusRuntimeEventInput input, GeneratedTusRuntimeEventRequest[] requests, String[] eventKeys) { this.scenarioId = scenarioId; + this.uploadLengthDeferred = uploadLengthDeferred; this.input = input; this.requests = requests; this.eventKeys = eventKeys; @@ -404,17 +508,20 @@ private static final class GeneratedTusRuntimeEventRequest { final String method; final String url; final int statusCode; - final GeneratedTusRuntimeEventHeader[] headers; + final GeneratedTusRuntimeEventHeader[] requestHeaders; + final GeneratedTusRuntimeEventHeader[] responseHeaders; GeneratedTusRuntimeEventRequest( String method, String url, int statusCode, - GeneratedTusRuntimeEventHeader[] headers) { + GeneratedTusRuntimeEventHeader[] requestHeaders, + GeneratedTusRuntimeEventHeader[] responseHeaders) { this.method = method; this.url = url; this.statusCode = statusCode; - this.headers = headers; + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; } } diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index 9bd1ec5a..33211f2b 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -107,6 +107,40 @@ public void testCreateUpload() throws IOException, ProtocolException { assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); } + + /** + * Verifies if uploads can be created with deferred upload length. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreateUploadWithDeferredLength() throws IOException, ProtocolException { + mockServer.when(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Upload-Defer-Length", "1")) + .respond(new HttpResponse() + .withStatusCode(201) + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Location", mockServerURL + "/foo")); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setUploadLengthDeferred(true); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + TusUploader uploader = client.createUpload(upload); + HttpRequest[] requests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("POST") + .withPath("/files")); + + assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); + assertEquals(1, requests.length); + assertFalse(requests[0].containsHeader("Upload-Length")); + } + /** * Verifies if uploads can be created with the tus client through a proxy. * @throws IOException if upload data cannot be read. diff --git a/src/test/java/io/tus/java/client/TestTusUploader.java b/src/test/java/io/tus/java/client/TestTusUploader.java index b81e2b75..b1a7b70c 100644 --- a/src/test/java/io/tus/java/client/TestTusUploader.java +++ b/src/test/java/io/tus/java/client/TestTusUploader.java @@ -74,6 +74,42 @@ public void testTusUploader() throws IOException, ProtocolException { uploader.finish(); } + /** + * Tests if deferred-length uploads declare the upload length on the first PATCH request. + * @throws IOException + * @throws ProtocolException + */ + @Test + public void testTusUploaderDeclaresDeferredLength() throws IOException, ProtocolException { + byte[] content = "hello world".getBytes(); + + mockServer.when(new HttpRequest() + .withPath("/files/deferred") + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Upload-Length", "11") + .withHeader("Upload-Offset", "0") + .withHeader("Content-Type", "application/offset+octet-stream") + .withBody(content)) + .respond(new HttpResponse() + .withStatusCode(204) + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Upload-Offset", "11")); + + TusClient client = new TusClient(); + URL uploadUrl = new URL(mockServerURL + "/deferred"); + TusInputStream input = new TusInputStream(new ByteArrayInputStream(content)); + TusUpload upload = new TusUpload(); + upload.setSize(11); + upload.setUploadLengthDeferred(true); + + TusUploader uploader = new TusUploader(client, upload, uploadUrl, input, 0); + + uploader.setChunkSize(100); + assertEquals(11, uploader.uploadChunk()); + assertEquals(-1, uploader.uploadChunk()); + uploader.finish(); + } + /** * Tests if the {@link TusUploader} actually uploads files through a proxy. * @throws IOException From 78a231b9595a4a09f69b76ac81118231642fe172 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 07:57:42 +0200 Subject: [PATCH 25/96] Regenerate TUS event contract --- .../tus/java/client/GeneratedTusClientConformanceScenarios.java | 1 + .../io/tus/java/client/TestGeneratedTusConformanceEvents.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 5775ae46..b7590b76 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -86,6 +86,7 @@ final class GeneratedTusClientConformanceScenarios { "progress:0:11", "progress:5:11", "upload-url-available", + "chunk-complete:5:5:11", "progress:5:11", "progress:10:11", "chunk-complete:5:10:11", diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 40526792..b409cc71 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -49,6 +49,7 @@ public class TestGeneratedTusConformanceEvents { "progress:0:11", "progress:5:11", "upload-url-available", + "chunk-complete:5:5:11", "progress:5:11", "progress:10:11", "chunk-complete:5:10:11", From 5c1f6767eb85a13868d7b41a03586eaffed1ff1f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 08:18:22 +0200 Subject: [PATCH 26/96] Carry generated TUS event policy --- ...eneratedTusClientConformanceScenarios.java | 175 ++++++++++++++++++ .../client/GeneratedTusProtocolContract.java | 21 +++ .../TestGeneratedTusConformanceEvents.java | 112 ++++++++++- .../client/TestGeneratedTusRuntimeEvents.java | 69 ++++++- 4 files changed, 372 insertions(+), 5 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index b7590b76..bcc84f1f 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -32,6 +32,11 @@ final class GeneratedTusClientConformanceScenarios { "emit-progress", "abort-current-request", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "fingerprint:contract-single-fingerprint", "upload-url-available", @@ -58,6 +63,11 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:11:11", @@ -82,6 +92,11 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:5:11", @@ -111,6 +126,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:11:11", @@ -134,6 +154,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:5:11", @@ -155,6 +180,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -169,6 +199,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -183,6 +218,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -197,6 +237,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -211,6 +256,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -225,6 +275,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -239,6 +294,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -253,6 +313,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -267,6 +332,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -281,6 +351,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -297,6 +372,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -313,6 +393,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -330,6 +415,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "send-upload-body-headers", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -347,6 +437,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "apply-custom-request-headers", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -366,6 +461,11 @@ final class GeneratedTusClientConformanceScenarios { "resume-from-previous-upload", "store-resume-url", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "fingerprint:contract-resume-fingerprint", "url-storage-find:contract-resume-fingerprint:1", @@ -394,6 +494,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "resolve-relative-location", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:0:11", @@ -418,6 +523,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:array-buffer:11", "success", @@ -439,6 +549,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:array-buffer-view:11", "success", @@ -460,6 +575,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-web-stream", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:web-readable-stream:null", "success", @@ -481,6 +601,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-stream", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:node-readable-stream:null", "success", @@ -502,6 +627,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-file", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:node-path-reference:11", "success", @@ -524,6 +654,11 @@ final class GeneratedTusClientConformanceScenarios { "defer-upload-length", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:0:11", @@ -548,6 +683,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "override-patch-method", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -569,6 +709,11 @@ final class GeneratedTusClientConformanceScenarios { "concatenate-partial-uploads", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:5:11", "chunk-complete:5:5:11", @@ -597,6 +742,11 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "concatenate-partial-uploads", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:3", } @@ -621,6 +771,11 @@ final class GeneratedTusClientConformanceScenarios { "retry-with-backoff", "recover-offset-after-error", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "should-retry:0:true", "retry-schedule:0", @@ -642,6 +797,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "run-request-hooks", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "before-request:0", "after-response:0", @@ -663,6 +823,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "abort-current-request", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:0", } @@ -684,6 +849,11 @@ final class GeneratedTusClientConformanceScenarios { "abort-current-request", "terminate-upload", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:1", } @@ -706,6 +876,11 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "retry-with-backoff", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), }; diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 13c1127b..dbaf8c53 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1304,6 +1304,7 @@ static final class GeneratedTusClientConformanceScenario { final String scenarioId; final String[] operationIds; final String[] primitives; + final GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; GeneratedTusClientConformanceScenario( @@ -1313,6 +1314,7 @@ static final class GeneratedTusClientConformanceScenario { String scenarioId, String[] operationIds, String[] primitives, + GeneratedTusClientConformanceEventPolicy eventPolicy, String[] eventKeys) { this.behavior = behavior; this.completionKind = completion.kind; @@ -1321,10 +1323,29 @@ static final class GeneratedTusClientConformanceScenario { this.scenarioId = scenarioId; this.operationIds = operationIds; this.primitives = primitives; + this.eventPolicy = eventPolicy; this.eventKeys = eventKeys; } } + /** + * Generated client conformance event policy fixture. + */ + static final class GeneratedTusClientConformanceEventPolicy { + final String matching; + final String progress; + final String transportProgress; + + GeneratedTusClientConformanceEventPolicy( + String matching, + String progress, + String transportProgress) { + this.matching = matching; + this.progress = progress; + this.transportProgress = transportProgress; + } + } + /** * Generated client conformance completion fixture. */ diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index b409cc71..ec0f1b20 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -20,6 +20,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "singleUploadLifecycle", "singleUploadLifecycle", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "fingerprint:contract-single-fingerprint", "upload-url-available", @@ -34,6 +39,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "creationWithUpload", "creationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:11:11", @@ -45,6 +55,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "creationWithUpload", "creationWithUploadPartialChunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:5:11", @@ -63,6 +78,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "protocolVersionSelection", "ietfDraft05CreationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:11:11", @@ -74,6 +94,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "protocolVersionSelection", "ietfDraft03ResumeWithoutKnownLength", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:5:11", @@ -86,6 +111,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "resumeUpload", "resumeFromPreviousUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "fingerprint:contract-resume-fingerprint", "url-storage-find:contract-resume-fingerprint:1", @@ -102,6 +132,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "relativeLocationResolution", "relativeLocationResolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:0:11", @@ -114,6 +149,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "inputSources", "arrayBufferInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:array-buffer:11", "success", @@ -123,6 +163,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "inputSources", "arrayBufferViewInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:array-buffer-view:11", "success", @@ -132,6 +177,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "inputSources", "webReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:web-readable-stream:null", "success", @@ -141,6 +191,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "inputSources", "nodeReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:node-readable-stream:null", "success", @@ -150,6 +205,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "inputSources", "nodePathInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:node-path-reference:11", "success", @@ -159,6 +219,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "deferredLengthUpload", "deferredLengthUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:0:11", @@ -171,6 +236,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "parallelUploadConcat", "parallelUploadConcat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:5:11", "chunk-complete:5:5:11", @@ -181,6 +251,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "parallelUploadConcat", "parallelUploadAbortCleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:3", } @@ -188,6 +263,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "retryOffsetRecovery", "retryPatchAfterOffsetRecovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "should-retry:0:true", "retry-schedule:0", @@ -198,6 +278,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "requestLifecycleHooks", "requestLifecycleHooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "before-request:0", "after-response:0", @@ -208,6 +293,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "abortUpload", "abortUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:0", } @@ -215,6 +305,11 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusEventCanaryCase( "abortUpload", "abortUploadAfterStoredUrl", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:1", } @@ -234,6 +329,7 @@ public void testGeneratedScenarioEventKeys() { assertEquals(testCase.featureId, scenario.featureId); assertContains(feature.conformance.scenarioIds, scenario.scenarioId); + assertEventPolicyEquals(testCase.eventPolicy, scenario.eventPolicy); assertArrayEquals(testCase.eventKeys, scenario.eventKeys); } } @@ -272,14 +368,28 @@ private static void assertContains(String[] values, String expected) { throw new AssertionError("Missing generated value: " + expected); } + private static void assertEventPolicyEquals( + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy expected, + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy actual) { + assertEquals(expected.matching, actual.matching); + assertEquals(expected.progress, actual.progress); + assertEquals(expected.transportProgress, actual.transportProgress); + } + private static final class GeneratedTusEventCanaryCase { final String featureId; final String scenarioId; + final GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; - GeneratedTusEventCanaryCase(String featureId, String scenarioId, String[] eventKeys) { + GeneratedTusEventCanaryCase( + String featureId, + String scenarioId, + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy, + String[] eventKeys) { this.featureId = featureId; this.scenarioId = scenarioId; + this.eventPolicy = eventPolicy; this.eventKeys = eventKeys; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 284d1f83..186e6ba1 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -30,6 +30,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase[] { new GeneratedTusRuntimeEventCase( "singleUploadLifecycle", + "exact-except-extra-progress", false, new GeneratedTusRuntimeEventInput( "hello world", @@ -89,6 +90,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "resumeFromPreviousUpload", + "exact-except-extra-progress", false, new GeneratedTusRuntimeEventInput( "hello world", @@ -145,6 +147,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "relativeLocationResolution", + "exact-except-extra-progress", false, new GeneratedTusRuntimeEventInput( "hello world", @@ -204,6 +207,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "deferredLengthUpload", + "exact-except-extra-progress", true, new GeneratedTusRuntimeEventInput( "hello world", @@ -310,10 +314,7 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) } uploader.finish(); - assertArrayEquals( - testCase.scenarioId, - testCase.eventKeys, - events.toArray(new String[events.size()])); + assertEvents(testCase, events); assertStoredUploadState(testCase, urlStore); } } @@ -444,8 +445,66 @@ private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { } } + private void assertEvents(GeneratedTusRuntimeEventCase testCase, List events) { + if ("exact".equals(testCase.eventPolicyMatching)) { + assertArrayEquals( + testCase.scenarioId, + testCase.eventKeys, + events.toArray(new String[events.size()])); + return; + } + + if ("exact-except-extra-progress".equals(testCase.eventPolicyMatching)) { + assertEventsExactExceptExtraProgress(testCase, events); + return; + } + + throw new AssertionError( + "Unsupported generated event policy " + + testCase.eventPolicyMatching + + " for " + + testCase.scenarioId); + } + + private void assertEventsExactExceptExtraProgress( + GeneratedTusRuntimeEventCase testCase, + List events) { + int expectedIndex = 0; + for (String event : events) { + if ( + expectedIndex < testCase.eventKeys.length + && event.equals(testCase.eventKeys[expectedIndex])) { + expectedIndex += 1; + continue; + } + + if (event.startsWith("progress:")) { + continue; + } + + throw new AssertionError( + testCase.scenarioId + + " emitted unexpected non-progress event " + + event + + "; expected " + + java.util.Arrays.toString(testCase.eventKeys)); + } + + if (expectedIndex == testCase.eventKeys.length) { + return; + } + + throw new AssertionError( + testCase.scenarioId + + " did not emit every expected non-extra event; observed " + + events + + "; expected " + + java.util.Arrays.toString(testCase.eventKeys)); + } + private static final class GeneratedTusRuntimeEventCase { final String scenarioId; + final String eventPolicyMatching; final boolean uploadLengthDeferred; final GeneratedTusRuntimeEventInput input; final GeneratedTusRuntimeEventRequest[] requests; @@ -453,11 +512,13 @@ private static final class GeneratedTusRuntimeEventCase { GeneratedTusRuntimeEventCase( String scenarioId, + String eventPolicyMatching, boolean uploadLengthDeferred, GeneratedTusRuntimeEventInput input, GeneratedTusRuntimeEventRequest[] requests, String[] eventKeys) { this.scenarioId = scenarioId; + this.eventPolicyMatching = eventPolicyMatching; this.uploadLengthDeferred = uploadLengthDeferred; this.input = input; this.requests = requests; From 269dc2279c82122b87c5769f8e02105f20eb4da5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 08:27:25 +0200 Subject: [PATCH 27/96] Keep generated event fixtures lintable --- ...eneratedTusClientConformanceScenarios.java | 704 ++++++++++-------- .../client/GeneratedTusProtocolContract.java | 22 +- 2 files changed, 405 insertions(+), 321 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index bcc84f1f..37e09050 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -32,21 +32,23 @@ final class GeneratedTusClientConformanceScenarios { "emit-progress", "abort-current-request", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "fingerprint:contract-single-fingerprint", - "upload-url-available", - "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload", @@ -63,18 +65,20 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:0:11", - "progress:11:11", - "upload-url-available", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload-partial-chunk", @@ -92,25 +96,27 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:0:11", - "progress:5:11", - "upload-url-available", - "chunk-complete:5:5:11", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", - "progress:10:11", - "progress:11:11", - "chunk-complete:1:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload", @@ -126,18 +132,20 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:0:11", - "progress:11:11", - "upload-url-available", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", @@ -154,19 +162,21 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "upload-url-available", - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -180,12 +190,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -199,12 +211,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -218,12 +232,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -237,12 +253,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -256,12 +274,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -275,12 +295,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -294,12 +316,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -313,12 +337,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -332,12 +358,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -351,12 +379,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "detailed-error", @@ -372,12 +402,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "detailed-error", @@ -393,12 +425,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", @@ -415,12 +449,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "send-upload-body-headers", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "custom-request-headers", @@ -437,12 +473,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "apply-custom-request-headers", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "resume-from-previous-upload", @@ -461,23 +499,25 @@ final class GeneratedTusClientConformanceScenarios { "resume-from-previous-upload", "store-resume-url", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "fingerprint:contract-resume-fingerprint", - "url-storage-find:contract-resume-fingerprint:1", - "fingerprint:contract-resume-fingerprint", - "upload-url-available", - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - "url-storage-remove:tus::contract-resume-fingerprint::1337", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "relative-location-resolution", @@ -494,19 +534,21 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "resolve-relative-location", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "upload-url-available", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "array-buffer-input", @@ -523,16 +565,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:array-buffer:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "array-buffer-view-input", @@ -549,16 +593,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:array-buffer-view:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "web-readable-stream-input", @@ -575,16 +621,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-web-stream", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:web-readable-stream:null", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "node-readable-stream-input", @@ -601,16 +649,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-stream", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:node-readable-stream:null", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "node-path-input", @@ -627,16 +677,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-file", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:node-path-reference:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "deferred-length-upload", @@ -654,19 +706,21 @@ final class GeneratedTusClientConformanceScenarios { "defer-upload-length", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "upload-url-available", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "override-patch-method", @@ -683,12 +737,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "override-patch-method", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "parallel-upload-concat", @@ -709,17 +765,19 @@ final class GeneratedTusClientConformanceScenarios { "concatenate-partial-uploads", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:5:11", - "chunk-complete:5:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "parallel-upload-abort-cleanup", @@ -742,14 +800,16 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "concatenate-partial-uploads", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "request-abort:3", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:3", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "retry-patch-after-offset-recovery", @@ -771,17 +831,19 @@ final class GeneratedTusClientConformanceScenarios { "retry-with-backoff", "recover-offset-after-error", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "should-retry:0:true", - "retry-schedule:0", - "should-retry:0:true", - "retry-schedule:0", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "request-lifecycle-hooks", @@ -797,17 +859,19 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "run-request-hooks", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "before-request:0", - "after-response:0", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "abort-upload", @@ -823,14 +887,16 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "abort-current-request", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "request-abort:0", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:0", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "abort-upload-after-stored-url", @@ -849,14 +915,16 @@ final class GeneratedTusClientConformanceScenarios { "abort-current-request", "terminate-upload", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "request-abort:1", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:1", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "terminate-with-retry", @@ -876,12 +944,14 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "retry-with-backoff", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), }; diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index dbaf8c53..b8ccc4f9 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1314,8 +1314,7 @@ static final class GeneratedTusClientConformanceScenario { String scenarioId, String[] operationIds, String[] primitives, - GeneratedTusClientConformanceEventPolicy eventPolicy, - String[] eventKeys) { + GeneratedTusClientConformanceEvents events) { this.behavior = behavior; this.completionKind = completion.kind; this.completionReason = completion.reason; @@ -1323,8 +1322,23 @@ static final class GeneratedTusClientConformanceScenario { this.scenarioId = scenarioId; this.operationIds = operationIds; this.primitives = primitives; - this.eventPolicy = eventPolicy; - this.eventKeys = eventKeys; + this.eventPolicy = events.policy; + this.eventKeys = events.keys; + } + } + + /** + * Generated client conformance event fixture bundle. + */ + static final class GeneratedTusClientConformanceEvents { + final GeneratedTusClientConformanceEventPolicy policy; + final String[] keys; + + GeneratedTusClientConformanceEvents( + GeneratedTusClientConformanceEventPolicy policy, + String[] keys) { + this.policy = policy; + this.keys = keys; } } From bca3207f4bb1051dac7d22a3a2e72184f81b048d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:25:16 +0200 Subject: [PATCH 28/96] Update generated TUS retry events --- .../GeneratedTusClientConformanceScenarios.java | 5 ++++- .../client/TestGeneratedTusConformanceEvents.java | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 37e09050..4eadec8a 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -950,7 +950,10 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[] { + "should-retry:0:true", + "retry-schedule:0", + } ) ), }; diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index ec0f1b20..2b2c91db 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -314,6 +314,19 @@ public class TestGeneratedTusConformanceEvents { "request-abort:1", } ), + new GeneratedTusEventCanaryCase( + "terminateUpload", + "terminateWithRetry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + } + ), }; /** From 86cdecd453578d3b6a69035c19b7a23e7e7d5414 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:58:03 +0200 Subject: [PATCH 29/96] Add generated TUS proof profile canaries --- .../TestGeneratedTusConformanceEvents.java | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 2b2c91db..15c88982 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -329,6 +329,87 @@ public class TestGeneratedTusConformanceEvents { ), }; + private static final GeneratedTusProofProfileCase[] PROOF_CASES = + new GeneratedTusProofProfileCase[] { + new GeneratedTusProofProfileCase( + "urlStorageCreateFlow", + "single-upload-lifecycle", + "success", + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + } + ), + new GeneratedTusProofProfileCase( + "customRequestHeaders", + "custom-request-headers", + "success", + "customRequestHeaders", + "customRequestHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + } + ), + new GeneratedTusProofProfileCase( + "overridePatchMethod", + "override-patch-method", + "success", + "overridePatchMethod", + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + } + ), + new GeneratedTusProofProfileCase( + "nodePathFileUpload", + "node-path-input", + "success", + "inputSources", + "nodePathInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + } + ), + new GeneratedTusProofProfileCase( + "resumeFromPreviousUpload", + "resume-from-previous-upload", + "success", + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + } + ), + }; + /** * Verifies generated feature-level event keys survive in the Java fixture. */ @@ -347,6 +428,26 @@ public void testGeneratedScenarioEventKeys() { } } + /** + * Verifies generated named proof-profile scenarios survive in the Java fixture. + */ + @Test + public void testGeneratedProofProfileScenarios() { + for (GeneratedTusProofProfileCase testCase : PROOF_CASES) { + GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario = + findScenario(testCase.scenarioId); + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature(testCase.featureId); + + assertEquals(testCase.behavior, scenario.behavior); + assertEquals(testCase.completionKind, scenario.completionKind); + assertEquals(testCase.featureId, scenario.featureId); + assertContains(feature.conformance.scenarioIds, scenario.scenarioId); + assertArrayEquals(testCase.operationIds, scenario.operationIds); + assertArrayEquals(testCase.primitives, scenario.primitives); + } + } + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( String featureId) { for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature @@ -406,4 +507,31 @@ private static final class GeneratedTusEventCanaryCase { this.eventKeys = eventKeys; } } + + private static final class GeneratedTusProofProfileCase { + final String profile; + final String behavior; + final String completionKind; + final String featureId; + final String scenarioId; + final String[] operationIds; + final String[] primitives; + + GeneratedTusProofProfileCase( + String profile, + String behavior, + String completionKind, + String featureId, + String scenarioId, + String[] operationIds, + String[] primitives) { + this.profile = profile; + this.behavior = behavior; + this.completionKind = completionKind; + this.featureId = featureId; + this.scenarioId = scenarioId; + this.operationIds = operationIds; + this.primitives = primitives; + } + } } From c34bd54274f12c34c6710df0be2e02c9a1daeca5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 10:26:45 +0200 Subject: [PATCH 30/96] Use generated TUS execution hints in runtime tests --- .../client/TestGeneratedTusRuntimeEvents.java | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 186e6ba1..b900fc5a 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -32,6 +32,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "singleUploadLifecycle", "exact-except-extra-progress", false, + new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( "hello world", "generated-contract", @@ -92,6 +93,13 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "resumeFromPreviousUpload", "exact-except-extra-progress", false, + new GeneratedTusRuntimeBeforeStartAction[] { + new GeneratedTusRuntimeBeforeStartAction( + "resume-from-previous-upload", + 1, + 0 + ), + }, new GeneratedTusRuntimeEventInput( "hello world", "resume-contract", @@ -149,6 +157,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "relativeLocationResolution", "exact-except-extra-progress", false, + new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( "hello world", "relative-contract", @@ -209,6 +218,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "deferredLengthUpload", "exact-except-extra-progress", true, + new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( "hello world", "deferred-contract", @@ -283,7 +293,11 @@ public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Except TusClient client = new TusClient(); client.setUploadCreationURL(endpointUrlFor(testCase)); GeneratedTusRuntimeEventUrlStore urlStore = urlStoreFor(testCase); - if (urlStore != null) { + if (hasResumeBeforeStartAction(testCase)) { + if (urlStore == null) { + throw new AssertionError( + testCase.scenarioId + " cannot resume without generated URL storage"); + } client.enableResuming(urlStore); } if ( @@ -321,13 +335,52 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) private TusUploader uploaderFor(TusClient client, GeneratedTusRuntimeEventCase testCase) throws Exception { - if (testCase.input.storedUpload != null) { + GeneratedTusRuntimeBeforeStartAction resumeAction = resumeBeforeStartAction(testCase); + if (resumeAction != null) { + assertStoredUploadAvailableForResume(testCase, resumeAction); return client.resumeUpload(uploadFor(testCase)); } return client.createUpload(uploadFor(testCase)); } + private boolean hasResumeBeforeStartAction(GeneratedTusRuntimeEventCase testCase) { + return resumeBeforeStartAction(testCase) != null; + } + + private GeneratedTusRuntimeBeforeStartAction resumeBeforeStartAction( + GeneratedTusRuntimeEventCase testCase) { + GeneratedTusRuntimeBeforeStartAction action = null; + for (GeneratedTusRuntimeBeforeStartAction candidate : testCase.beforeStartActions) { + if (!"resume-from-previous-upload".equals(candidate.kind)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated beforeStart action " + + candidate.kind); + } + + if (action != null) { + throw new AssertionError( + testCase.scenarioId + " defines more than one resume beforeStart action"); + } + + action = candidate; + } + + return action; + } + + private void assertStoredUploadAvailableForResume( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeBeforeStartAction action) { + if (testCase.input.storedUpload == null) { + throw new AssertionError( + testCase.scenarioId + " cannot resume without a generated stored upload"); + } + assertEquals(testCase.scenarioId, 0, action.selectedPreviousUploadIndex); + assertEquals(testCase.scenarioId, 1, action.expectedPreviousUploadCount); + } + private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { byte[] content = testCase.input.content.getBytes(StandardCharsets.UTF_8); TusUpload upload = new TusUpload(); @@ -506,6 +559,7 @@ private static final class GeneratedTusRuntimeEventCase { final String scenarioId; final String eventPolicyMatching; final boolean uploadLengthDeferred; + final GeneratedTusRuntimeBeforeStartAction[] beforeStartActions; final GeneratedTusRuntimeEventInput input; final GeneratedTusRuntimeEventRequest[] requests; final String[] eventKeys; @@ -514,18 +568,35 @@ private static final class GeneratedTusRuntimeEventCase { String scenarioId, String eventPolicyMatching, boolean uploadLengthDeferred, + GeneratedTusRuntimeBeforeStartAction[] beforeStartActions, GeneratedTusRuntimeEventInput input, GeneratedTusRuntimeEventRequest[] requests, String[] eventKeys) { this.scenarioId = scenarioId; this.eventPolicyMatching = eventPolicyMatching; this.uploadLengthDeferred = uploadLengthDeferred; + this.beforeStartActions = beforeStartActions; this.input = input; this.requests = requests; this.eventKeys = eventKeys; } } + private static final class GeneratedTusRuntimeBeforeStartAction { + final String kind; + final int expectedPreviousUploadCount; + final int selectedPreviousUploadIndex; + + GeneratedTusRuntimeBeforeStartAction( + String kind, + int expectedPreviousUploadCount, + int selectedPreviousUploadIndex) { + this.kind = kind; + this.expectedPreviousUploadCount = expectedPreviousUploadCount; + this.selectedPreviousUploadIndex = selectedPreviousUploadIndex; + } + } + private static final class GeneratedTusRuntimeEventInput { final String content; final String uploadPath; From 25c42b7f47de287fe7875e4b6e5fbf2f73215b41 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:37:34 +0200 Subject: [PATCH 31/96] Expose TUS managed upload contract --- .../java/io/tus/java/client/GeneratedTusProtocolContract.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index b8ccc4f9..b0e72032 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,6 +1121,8 @@ final class GeneratedTusProtocolContract { ), }; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; From fe0fbf6443d24d230e60a522e92a267491068f5d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:52:40 +0200 Subject: [PATCH 32/96] Expose managed upload proof cases --- .../client/GeneratedTusProtocolContract.java | 132 ++++++++++++++++++ .../TestGeneratedTusConformanceEvents.java | 25 ++++ 2 files changed, 157 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index b0e72032..a01c5927 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1123,6 +1123,111 @@ final class GeneratedTusProtocolContract { static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String[] MANAGED_UPLOAD_PRIMITIVES = + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }; + + static final String[] MANAGED_UPLOAD_RUNTIME_PROFILES = + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + }; + + static final String[] MANAGED_UPLOAD_SCENARIO_IDS = + new String[] { + "managedUploadDurableRetry", + "managedUploadPermanentFailure", + "managedUploadNetworkConstraint", + }; + + static final GeneratedTusManagedUploadProofCase[] MANAGED_UPLOAD_PROOF_CASES = + new GeneratedTusManagedUploadProofCase[] { + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadDurableRetry", + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadPermanentFailure", + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadNetworkConstraint", + new String[] { + "accept-upload-submission", + "schedule-upload-work", + "publish-upload-state", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + }; + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; @@ -1295,6 +1400,33 @@ static final class GeneratedTusClientFeatureFlowStep { } } + /** + * Generated managed-upload feature proof fixture. + */ + static final class GeneratedTusManagedUploadProofCase { + final String featureId; + final String layer; + final String scenarioId; + final String[] requiredPrimitives; + final String[] protocolFeatureIds; + final String[] runtimeProfiles; + + GeneratedTusManagedUploadProofCase( + String featureId, + String layer, + String scenarioId, + String[] requiredPrimitives, + String[] protocolFeatureIds, + String[] runtimeProfiles) { + this.featureId = featureId; + this.layer = layer; + this.scenarioId = scenarioId; + this.requiredPrimitives = requiredPrimitives; + this.protocolFeatureIds = protocolFeatureIds; + this.runtimeProfiles = runtimeProfiles; + } + } + /** * Generated client conformance scenario fixture. */ diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 15c88982..65b15407 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -448,6 +448,31 @@ public void testGeneratedProofProfileScenarios() { } } + /** + * Verifies managed-upload proof scenarios stay wired to protocol features and primitives. + */ + @Test + public void testGeneratedManagedUploadProofScenarios() { + for (GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase testCase + : GeneratedTusProtocolContract.MANAGED_UPLOAD_PROOF_CASES) { + assertEquals("managedUpload", testCase.featureId); + assertEquals("feature-over-protocol", testCase.layer); + assertContains( + GeneratedTusProtocolContract.MANAGED_UPLOAD_SCENARIO_IDS, + testCase.scenarioId); + assertArrayEquals( + GeneratedTusProtocolContract.MANAGED_UPLOAD_RUNTIME_PROFILES, + testCase.runtimeProfiles); + + for (String primitive : testCase.requiredPrimitives) { + assertContains(GeneratedTusProtocolContract.MANAGED_UPLOAD_PRIMITIVES, primitive); + } + for (String featureId : testCase.protocolFeatureIds) { + findFeature(featureId); + } + } + } + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( String featureId) { for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature From 2b623965259203d5d53ca6f3c3f60d36a3b5eef7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:17:07 +0200 Subject: [PATCH 33/96] Add managed upload runtime proof --- .../client/GeneratedTusProtocolContract.java | 2 +- .../TestGeneratedTusManagedUploadRuntime.java | 650 ++++++++++++++++++ .../client/TestGeneratedTusRuntimeEvents.java | 67 +- 3 files changed, 710 insertions(+), 9 deletions(-) create mode 100644 src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index a01c5927..6539c66a 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proof\": {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"stateBackend\": \"filesystem\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ]\n },\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java new file mode 100644 index 00000000..41b9b36b --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -0,0 +1,650 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.java.client; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.Test; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests generated managed-upload scenarios against the real Java client pieces. + */ +public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { + private static final GeneratedTusManagedUploadRuntimeCase[] CASES = + new GeneratedTusManagedUploadRuntimeCase[] { + new GeneratedTusManagedUploadRuntimeCase( + "managedUploadDurableRetry", + new GeneratedTusManagedUploadRuntimeProfile( + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "filesystem" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadCleanup( + "remove-owned-source-after-success", + "remove-after-success" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + "running", + "succeeded", + }, + new int[] { + 0, + } + ), + new GeneratedTusManagedUploadInput( + "hello managed!", + 7, + "managed-durable-retry-fingerprint", + "managed-durable-retry", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "io-error", + 7 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 201, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Location", + "https://tus.io/uploads/managed-durable-retry" + ), + } + ), + new GeneratedTusManagedUploadRequest( + "PATCH", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "succeeded", + null, + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "HEAD", + "upload", + 0, + 200, + new GeneratedTusManagedUploadHeader[0], + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ), + new GeneratedTusManagedUploadRequest( + "PATCH", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "14" + ), + } + ), + } + ), + } + ), + }; + private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = + new GeneratedTusMethodOverride[] { + new GeneratedTusMethodOverride( + "PATCH", + "POST", + "X-HTTP-Method-Override", + "PATCH" + ), + }; + + /** + * Verifies a durable source can retry, resume, finish, and clean up from contract data. + */ + @Test + public void testManagedUploadDurableRetryRuntime() throws Exception { + for (GeneratedTusManagedUploadRuntimeCase testCase : CASES) { + mockServer.reset(); + registerResponses(testCase); + + List states = new ArrayList(); + File source = writeSourceFile(testCase); + File ownedSource = ownedSourceFile(testCase, source); + File stateFile = stateFile(testCase, source); + copyDurableSource(testCase, source, ownedSource); + recordState(testCase, states, stateFile, "pending"); + + final GeneratedTusManagedUploadUrlStore urlStore = new GeneratedTusManagedUploadUrlStore(); + final TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + client.enableResuming(urlStore); + client.enableRemoveFingerprintOnSuccess(); + + TusExecutor executor = managedExecutorFor(testCase, client, ownedSource, states, stateFile); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future future = worker.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTrue(testCase.scenarioId, future.get()); + } finally { + worker.shutdownNow(); + } + + cleanupAfterSuccess(testCase, ownedSource); + + assertArrayEquals( + testCase.scenarioId, + testCase.expectedStates, + states.toArray(new String[states.size()])); + assertArrayEquals( + testCase.scenarioId, + testCase.expectedStates, + Files.readAllLines(stateFile.toPath(), StandardCharsets.UTF_8) + .toArray(new String[testCase.expectedStates.length])); + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + assertFalse(testCase.scenarioId, ownedSource.exists()); + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); + stateFile.delete(); + } + } + + private TusExecutor managedExecutorFor( + final GeneratedTusManagedUploadRuntimeCase testCase, + final TusClient client, + final File ownedSource, + final List states, + final File stateFile) { + TusExecutor executor = new TusExecutor() { + private int attemptIndex; + + @Override + protected void makeAttempt() throws ProtocolException, IOException { + GeneratedTusManagedUploadAttempt attempt = testCase.attempts[attemptIndex]; + attemptIndex += 1; + recordState(testCase, states, stateFile, "running"); + + TusUpload upload = uploadFor(testCase, ownedSource); + TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setRequestPayloadSize(testCase.input.chunkSize); + while (uploader.getOffset() < upload.getSize()) { + uploader.uploadChunk(); + if ( + attempt.failure != null + && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { + uploader.finish(false); + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + throw new IOException(attempt.failure.kind); + } + } + uploader.finish(); + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + } + }; + executor.setDelays(testCase.retryDelays); + return executor; + } + + private TusUpload uploadFor( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + TusUpload upload = new TusUpload(ownedSource); + upload.setFingerprint(testCase.input.fingerprint); + upload.setMetadata(metadataFor(testCase.input.metadata)); + return upload; + } + + private Map metadataFor(GeneratedTusManagedUploadMetadata[] metadata) { + Map result = new LinkedHashMap(); + for (GeneratedTusManagedUploadMetadata entry : metadata) { + result.put(entry.name, entry.value); + } + return result; + } + + private void copyDurableSource( + GeneratedTusManagedUploadRuntimeCase testCase, + File source, + File ownedSource) throws IOException { + if (!"copy-to-owned-storage".equals(testCase.sourceDurability)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source durability " + + testCase.sourceDurability); + } + + Files.copy(source.toPath(), ownedSource.toPath(), StandardCopyOption.REPLACE_EXISTING); + assertTrue(testCase.scenarioId, ownedSource.exists()); + } + + private void cleanupAfterSuccess( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + return; + } + + Files.deleteIfExists(ownedSource.toPath()); + } + + private void recordState( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + File stateFile, + String state) throws IOException { + if (!"filesystem".equals(testCase.stateBackend)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated state backend " + + testCase.stateBackend); + } + + states.add(state); + Files.write(stateFile.toPath(), states, StandardCharsets.UTF_8); + } + + private File writeSourceFile(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { + File source = File.createTempFile(testCase.scenarioId, "-source.bin"); + Files.write( + source.toPath(), + testCase.input.content.getBytes(StandardCharsets.UTF_8)); + return source; + } + + private File ownedSourceFile( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + return new File(source.getParentFile(), testCase.scenarioId + "-owned.bin"); + } + + private File stateFile( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + return new File(source.getParentFile(), testCase.scenarioId + "-state.txt"); + } + + private void registerResponses(GeneratedTusManagedUploadRuntimeCase testCase) throws Exception { + for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { + for (GeneratedTusManagedUploadRequest request : attempt.requests) { + mockServer.when(requestFor(testCase, request, request.method, null)) + .respond(responseFor(testCase, request)); + GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); + if (methodOverride != null) { + mockServer.when(requestFor(testCase, request, methodOverride.method, methodOverride)) + .respond(responseFor(testCase, request)); + } + } + } + } + + private HttpRequest requestFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request, + String method, + GeneratedTusMethodOverride methodOverride) throws Exception { + HttpRequest httpRequest = new HttpRequest() + .withMethod(method) + .withPath(pathFor(testCase, request)); + for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { + httpRequest.withHeader(header.name, header.value); + } + if (methodOverride != null) { + httpRequest.withHeader(methodOverride.headerName, methodOverride.headerValue); + } + return httpRequest; + } + + private GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { + for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { + if (methodOverride.originalMethod.equals(originalMethod)) { + return methodOverride; + } + } + + return null; + } + + private String pathFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request) throws Exception { + if ("endpoint".equals(request.url)) { + return mockServerURL.getPath(); + } + + return uploadUrlFor(testCase).getPath(); + } + + private HttpResponse responseFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request) throws Exception { + HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); + for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { + response.withHeader(header.name, headerValueFor(testCase, header)); + } + return response; + } + + private String headerValueFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadHeader header) throws Exception { + if (!testCase.locationHeaderName.equals(header.name)) { + return header.value; + } + + return uploadUrlFor(testCase).toString(); + } + + private URL uploadUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws Exception { + return new URL(mockServerURL.toString() + "/" + testCase.input.uploadPath); + } + + private static String offsetDiscoveryMethod() { + for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation + : GeneratedTusProtocolContract.OPERATIONS) { + if ("offset-discovery".equals(operation.role)) { + return operation.method; + } + } + + throw new AssertionError("Missing generated offset-discovery operation"); + } + + private static final class GeneratedTusManagedUploadRuntimeCase { + final String scenarioId; + final String runtime; + final String scheduler; + final String sourceDurability; + final String stateBackend; + final String locationHeaderName; + final String ownedSourceCleanup; + final String resumeUrlCleanup; + final String[] expectedStates; + final int[] retryDelays; + final String offsetDiscoveryMethod; + final GeneratedTusManagedUploadInput input; + final GeneratedTusManagedUploadAttempt[] attempts; + + GeneratedTusManagedUploadRuntimeCase( + String scenarioId, + GeneratedTusManagedUploadRuntimeProfile profile, + GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadCleanup cleanup, + GeneratedTusManagedUploadRetryPlan retryPlan, + GeneratedTusManagedUploadInput input, + GeneratedTusManagedUploadAttempt[] attempts) { + this.scenarioId = scenarioId; + this.runtime = profile.runtime; + this.scheduler = profile.scheduler; + this.sourceDurability = profile.sourceDurability; + this.stateBackend = profile.stateBackend; + this.locationHeaderName = transport.locationHeaderName; + this.ownedSourceCleanup = cleanup.ownedSource; + this.resumeUrlCleanup = cleanup.resumeUrl; + this.expectedStates = retryPlan.expectedStates; + this.retryDelays = retryPlan.retryDelays; + this.offsetDiscoveryMethod = offsetDiscoveryMethod(); + this.input = input; + this.attempts = attempts; + } + } + + private static final class GeneratedTusManagedUploadRuntimeProfile { + final String runtime; + final String scheduler; + final String sourceDurability; + final String stateBackend; + + GeneratedTusManagedUploadRuntimeProfile( + String runtime, + String scheduler, + String sourceDurability, + String stateBackend) { + this.runtime = runtime; + this.scheduler = scheduler; + this.sourceDurability = sourceDurability; + this.stateBackend = stateBackend; + } + } + + private static final class GeneratedTusManagedUploadTransport { + final String locationHeaderName; + + GeneratedTusManagedUploadTransport(String locationHeaderName) { + this.locationHeaderName = locationHeaderName; + } + } + + private static final class GeneratedTusManagedUploadCleanup { + final String ownedSource; + final String resumeUrl; + + GeneratedTusManagedUploadCleanup(String ownedSource, String resumeUrl) { + this.ownedSource = ownedSource; + this.resumeUrl = resumeUrl; + } + } + + private static final class GeneratedTusManagedUploadRetryPlan { + final String[] expectedStates; + final int[] retryDelays; + + GeneratedTusManagedUploadRetryPlan(String[] expectedStates, int[] retryDelays) { + this.expectedStates = expectedStates; + this.retryDelays = retryDelays; + } + } + + private static final class GeneratedTusManagedUploadInput { + final String content; + final int chunkSize; + final String fingerprint; + final String uploadPath; + final GeneratedTusManagedUploadMetadata[] metadata; + + GeneratedTusManagedUploadInput( + String content, + int chunkSize, + String fingerprint, + String uploadPath, + GeneratedTusManagedUploadMetadata[] metadata) { + this.content = content; + this.chunkSize = chunkSize; + this.fingerprint = fingerprint; + this.uploadPath = uploadPath; + this.metadata = metadata; + } + } + + private static final class GeneratedTusManagedUploadAttempt { + final int attemptIndex; + final String stateAfterAttempt; + final GeneratedTusManagedUploadFailure failure; + final GeneratedTusManagedUploadRequest[] requests; + + GeneratedTusManagedUploadAttempt( + int attemptIndex, + String stateAfterAttempt, + GeneratedTusManagedUploadFailure failure, + GeneratedTusManagedUploadRequest[] requests) { + this.attemptIndex = attemptIndex; + this.stateAfterAttempt = stateAfterAttempt; + this.failure = failure; + this.requests = requests; + } + } + + private static final class GeneratedTusManagedUploadFailure { + final String kind; + final long afterAcceptedOffset; + + GeneratedTusManagedUploadFailure(String kind, long afterAcceptedOffset) { + this.kind = kind; + this.afterAcceptedOffset = afterAcceptedOffset; + } + } + + private static final class GeneratedTusManagedUploadRequest { + final String method; + final String url; + final int bodySize; + final int statusCode; + final GeneratedTusManagedUploadHeader[] requestHeaders; + final GeneratedTusManagedUploadHeader[] responseHeaders; + + GeneratedTusManagedUploadRequest( + String method, + String url, + int bodySize, + int statusCode, + GeneratedTusManagedUploadHeader[] requestHeaders, + GeneratedTusManagedUploadHeader[] responseHeaders) { + this.method = method; + this.url = url; + this.bodySize = bodySize; + this.statusCode = statusCode; + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + } + } + + private static final class GeneratedTusManagedUploadHeader { + final String name; + final String value; + + GeneratedTusManagedUploadHeader(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusManagedUploadMetadata { + final String name; + final String value; + + GeneratedTusManagedUploadMetadata(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusMethodOverride { + final String originalMethod; + final String method; + final String headerName; + final String headerValue; + + GeneratedTusMethodOverride( + String originalMethod, + String method, + String headerName, + String headerValue) { + this.originalMethod = originalMethod; + this.method = method; + this.headerName = headerName; + this.headerValue = headerValue; + } + } + + private static final class GeneratedTusManagedUploadUrlStore implements TusURLStore { + private final Map values = new LinkedHashMap(); + + @Override + public URL get(String fingerprint) { + return values.get(fingerprint); + } + + @Override + public void set(String fingerprint, URL url) { + values.put(fingerprint, url); + } + + @Override + public void remove(String fingerprint) { + values.remove(fingerprint); + } + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index b900fc5a..2dbcd7be 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -280,6 +280,15 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), }; + private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = + new GeneratedTusMethodOverride[] { + new GeneratedTusMethodOverride( + "PATCH", + "POST", + "X-HTTP-Method-Override", + "PATCH" + ), + }; /** * Verifies the sync uploader emits generated progress and chunk-complete events. @@ -404,17 +413,41 @@ private Map metadataFor(GeneratedTusRuntimeEventMetadata[] metad private void registerResponses(GeneratedTusRuntimeEventCase testCase) throws Exception { for (GeneratedTusRuntimeEventRequest request : testCase.requests) { - HttpRequest httpRequest = new HttpRequest() - .withPath(pathFor(testCase, request)); - if (!"upload".equals(request.url) || "HEAD".equals(request.method)) { - httpRequest.withMethod(request.method); - } - for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { - httpRequest.withHeader(header.name, header.value); + mockServer.when(requestFor(testCase, request, request.method, null)) + .respond(responseFor(testCase, request)); + GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); + if (methodOverride != null) { + mockServer.when(requestFor(testCase, request, methodOverride.method, methodOverride)) + .respond(responseFor(testCase, request)); } + } + } - mockServer.when(httpRequest).respond(responseFor(testCase, request)); + private HttpRequest requestFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request, + String method, + GeneratedTusMethodOverride methodOverride) throws Exception { + HttpRequest httpRequest = new HttpRequest() + .withMethod(method) + .withPath(pathFor(testCase, request)); + for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { + httpRequest.withHeader(header.name, header.value); + } + if (methodOverride != null) { + httpRequest.withHeader(methodOverride.headerName, methodOverride.headerValue); } + return httpRequest; + } + + private GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { + for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { + if (methodOverride.originalMethod.equals(originalMethod)) { + return methodOverride; + } + } + + return null; } private String pathFor( @@ -677,6 +710,24 @@ private static final class GeneratedTusRuntimeEventMetadata { } } + private static final class GeneratedTusMethodOverride { + final String originalMethod; + final String method; + final String headerName; + final String headerValue; + + GeneratedTusMethodOverride( + String originalMethod, + String method, + String headerName, + String headerValue) { + this.originalMethod = originalMethod; + this.method = method; + this.headerName = headerName; + this.headerValue = headerValue; + } + } + private static final class GeneratedTusRuntimeEventUrlStore implements TusURLStore { private final Map values = new LinkedHashMap(); From c7e4c157b7a3d41af2e0cb71d26a60175ec09e68 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:43:36 +0200 Subject: [PATCH 34/96] Update managed upload proof fixture --- .../tus/java/client/GeneratedTusProtocolContract.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 6539c66a..79f17bc7 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proof\": {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"stateBackend\": \"filesystem\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ]\n },\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1158,6 +1158,10 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadDurableRetry", + new String[] { + "java", + "android", + }, new String[] { "accept-upload-submission", "make-source-durable", @@ -1184,6 +1188,7 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadPermanentFailure", + new String[0], new String[] { "accept-upload-submission", "make-source-durable", @@ -1208,6 +1213,7 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadNetworkConstraint", + new String[0], new String[] { "accept-upload-submission", "schedule-upload-work", @@ -1407,6 +1413,7 @@ static final class GeneratedTusManagedUploadProofCase { final String featureId; final String layer; final String scenarioId; + final String[] proofRuntimes; final String[] requiredPrimitives; final String[] protocolFeatureIds; final String[] runtimeProfiles; @@ -1415,12 +1422,14 @@ static final class GeneratedTusManagedUploadProofCase { String featureId, String layer, String scenarioId, + String[] proofRuntimes, String[] requiredPrimitives, String[] protocolFeatureIds, String[] runtimeProfiles) { this.featureId = featureId; this.layer = layer; this.scenarioId = scenarioId; + this.proofRuntimes = proofRuntimes; this.requiredPrimitives = requiredPrimitives; this.protocolFeatureIds = protocolFeatureIds; this.runtimeProfiles = runtimeProfiles; From 0f840a9d2880a5a4df3094c5a8c78376cc4620e0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:09:50 +0200 Subject: [PATCH 35/96] Add managed upload permanent failure proof --- .../client/GeneratedTusProtocolContract.java | 9 +- .../TestGeneratedTusManagedUploadRuntime.java | 230 ++++++++++++++++-- 2 files changed, 217 insertions(+), 22 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 79f17bc7..40426201 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1188,13 +1188,18 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadPermanentFailure", - new String[0], + new String[] { + "java", + "android", + }, new String[] { "accept-upload-submission", "make-source-durable", "schedule-upload-work", + "run-protocol-upload", "classify-failure", "publish-upload-state", + "cleanup-managed-upload", }, new String[] { "singleUploadLifecycle", diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 41b9b36b..102dc411 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -47,6 +48,10 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadTransport( "Location" ), + new GeneratedTusManagedUploadTerminal( + "succeeded", + "" + ), new GeneratedTusManagedUploadCleanup( "remove-owned-source-after-success", "remove-after-success" @@ -80,6 +85,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 0, "failed", new GeneratedTusManagedUploadFailure( + "after-accepted-offset", "io-error", 7 ), @@ -166,6 +172,72 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + "managedUploadPermanentFailure", + new GeneratedTusManagedUploadRuntimeProfile( + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "filesystem" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadTerminal( + "failed", + "unretryable-protocol-error" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-after-permanent-failure", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello failure!", + 7, + "managed-permanent-failure-fingerprint", + "managed-permanent-failure", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-permanent-failure.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "unretryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 400, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { @@ -208,12 +280,12 @@ public Boolean call() throws Exception { return executor.makeAttempts(); } }); - assertTrue(testCase.scenarioId, future.get()); + assertTerminalResult(testCase, future); } finally { worker.shutdownNow(); } - cleanupAfterSuccess(testCase, ownedSource); + cleanupAfterTerminalState(testCase, ownedSource); assertArrayEquals( testCase.scenarioId, @@ -224,14 +296,55 @@ public Boolean call() throws Exception { testCase.expectedStates, Files.readAllLines(stateFile.toPath(), StandardCharsets.UTF_8) .toArray(new String[testCase.expectedStates.length])); - assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); - assertFalse(testCase.scenarioId, ownedSource.exists()); + assertResumeUrlState(testCase, urlStore); + assertOwnedSourceState(testCase, ownedSource); assertTrue(testCase.scenarioId, source.exists()); source.delete(); stateFile.delete(); } } + private void assertTerminalResult( + GeneratedTusManagedUploadRuntimeCase testCase, + Future future) throws Exception { + try { + boolean result = future.get(); + if (!"succeeded".equals(testCase.terminalState)) { + throw new AssertionError(testCase.scenarioId + " expected terminal failure"); + } + assertTrue(testCase.scenarioId, result); + } catch (ExecutionException error) { + if (!"failed".equals(testCase.terminalState)) { + throw error; + } + assertTerminalFailure(testCase, error.getCause()); + } + } + + private void assertTerminalFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + Throwable error) { + if ("unretryable-protocol-error".equals(testCase.terminalFailure)) { + assertTrue(testCase.scenarioId, error instanceof ProtocolException); + return; + } + if ("source-unavailable".equals(testCase.terminalFailure)) { + assertTrue(testCase.scenarioId, error instanceof IOException); + return; + } + if ("retry-policy-exhausted".equals(testCase.terminalFailure)) { + assertTrue( + testCase.scenarioId, + error instanceof ProtocolException || error instanceof IOException); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated terminal failure " + + testCase.terminalFailure); + } + private TusExecutor managedExecutorFor( final GeneratedTusManagedUploadRuntimeCase testCase, final TusClient client, @@ -247,28 +360,53 @@ protected void makeAttempt() throws ProtocolException, IOException { attemptIndex += 1; recordState(testCase, states, stateFile, "running"); - TusUpload upload = uploadFor(testCase, ownedSource); - TusUploader uploader = client.resumeOrCreateUpload(upload); - uploader.setChunkSize(testCase.input.chunkSize); - uploader.setRequestPayloadSize(testCase.input.chunkSize); - while (uploader.getOffset() < upload.getSize()) { - uploader.uploadChunk(); - if ( - attempt.failure != null - && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { - uploader.finish(false); - recordState(testCase, states, stateFile, attempt.stateAfterAttempt); - throw new IOException(attempt.failure.kind); + try { + TusUpload upload = uploadFor(testCase, ownedSource); + TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setRequestPayloadSize(testCase.input.chunkSize); + while (uploader.getOffset() < upload.getSize()) { + uploader.uploadChunk(); + if ( + isAfterAcceptedOffsetFailure(attempt) + && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { + uploader.finish(false); + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + throw new IOException(attempt.failure.kind); + } } + uploader.finish(); + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + } catch (ProtocolException error) { + recordDuringProtocolFailure(testCase, states, stateFile, attempt); + throw error; + } catch (IOException error) { + recordDuringProtocolFailure(testCase, states, stateFile, attempt); + throw error; } - uploader.finish(); - recordState(testCase, states, stateFile, attempt.stateAfterAttempt); } }; executor.setDelays(testCase.retryDelays); return executor; } + private boolean isAfterAcceptedOffsetFailure(GeneratedTusManagedUploadAttempt attempt) { + return attempt.failure != null + && "after-accepted-offset".equals(attempt.failure.phase); + } + + private void recordDuringProtocolFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + File stateFile, + GeneratedTusManagedUploadAttempt attempt) throws IOException { + if (attempt.failure == null || !"during-protocol-request".equals(attempt.failure.phase)) { + return; + } + + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + } + private TusUpload uploadFor( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { @@ -301,7 +439,7 @@ private void copyDurableSource( assertTrue(testCase.scenarioId, ownedSource.exists()); } - private void cleanupAfterSuccess( + private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { @@ -311,6 +449,41 @@ private void cleanupAfterSuccess( Files.deleteIfExists(ownedSource.toPath()); } + private void assertOwnedSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) { + if ("remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + assertFalse(testCase.scenarioId, ownedSource.exists()); + return; + } + if ("retain-owned-source-after-permanent-failure".equals(testCase.ownedSourceCleanup)) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated owned-source cleanup " + + testCase.ownedSourceCleanup); + } + + private void assertResumeUrlState( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadUrlStore urlStore) { + if ( + "remove-after-success".equals(testCase.resumeUrlCleanup) + || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup)) { + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated resume URL cleanup " + + testCase.resumeUrlCleanup); + } + private void recordState( GeneratedTusManagedUploadRuntimeCase testCase, List states, @@ -440,6 +613,8 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String sourceDurability; final String stateBackend; final String locationHeaderName; + final String terminalState; + final String terminalFailure; final String ownedSourceCleanup; final String resumeUrlCleanup; final String[] expectedStates; @@ -452,6 +627,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { String scenarioId, GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadTerminal terminal, GeneratedTusManagedUploadCleanup cleanup, GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, @@ -462,6 +638,8 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.sourceDurability = profile.sourceDurability; this.stateBackend = profile.stateBackend; this.locationHeaderName = transport.locationHeaderName; + this.terminalState = terminal.state; + this.terminalFailure = terminal.failure; this.ownedSourceCleanup = cleanup.ownedSource; this.resumeUrlCleanup = cleanup.resumeUrl; this.expectedStates = retryPlan.expectedStates; @@ -472,6 +650,16 @@ private static final class GeneratedTusManagedUploadRuntimeCase { } } + private static final class GeneratedTusManagedUploadTerminal { + final String state; + final String failure; + + GeneratedTusManagedUploadTerminal(String state, String failure) { + this.state = state; + this.failure = failure; + } + } + private static final class GeneratedTusManagedUploadRuntimeProfile { final String runtime; final String scheduler; @@ -558,10 +746,12 @@ private static final class GeneratedTusManagedUploadAttempt { } private static final class GeneratedTusManagedUploadFailure { + final String phase; final String kind; final long afterAcceptedOffset; - GeneratedTusManagedUploadFailure(String kind, long afterAcceptedOffset) { + GeneratedTusManagedUploadFailure(String phase, String kind, long afterAcceptedOffset) { + this.phase = phase; this.kind = kind; this.afterAcceptedOffset = afterAcceptedOffset; } From cf572c4e7aa75118ae87c3979442402f893d4c42 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:20:44 +0200 Subject: [PATCH 36/96] Respect managed upload fixture lint --- .../client/TestGeneratedTusManagedUploadRuntime.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 102dc411..aaf02bfa 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -38,8 +38,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { private static final GeneratedTusManagedUploadRuntimeCase[] CASES = new GeneratedTusManagedUploadRuntimeCase[] { new GeneratedTusManagedUploadRuntimeCase( - "managedUploadDurableRetry", new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadDurableRetry", "java", "process-lifetime-worker-pool", "copy-to-owned-storage", @@ -173,8 +173,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { } ), new GeneratedTusManagedUploadRuntimeCase( - "managedUploadPermanentFailure", new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadPermanentFailure", "java", "process-lifetime-worker-pool", "copy-to-owned-storage", @@ -624,7 +624,6 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final GeneratedTusManagedUploadAttempt[] attempts; GeneratedTusManagedUploadRuntimeCase( - String scenarioId, GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadTransport transport, GeneratedTusManagedUploadTerminal terminal, @@ -632,7 +631,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, GeneratedTusManagedUploadAttempt[] attempts) { - this.scenarioId = scenarioId; + this.scenarioId = profile.scenarioId; this.runtime = profile.runtime; this.scheduler = profile.scheduler; this.sourceDurability = profile.sourceDurability; @@ -661,16 +660,19 @@ private static final class GeneratedTusManagedUploadTerminal { } private static final class GeneratedTusManagedUploadRuntimeProfile { + final String scenarioId; final String runtime; final String scheduler; final String sourceDurability; final String stateBackend; GeneratedTusManagedUploadRuntimeProfile( + String scenarioId, String runtime, String scheduler, String sourceDurability, String stateBackend) { + this.scenarioId = scenarioId; this.runtime = runtime; this.scheduler = scheduler; this.sourceDurability = sourceDurability; From 862a262de5d6923f6479c4fb76f38ecdc27461ef Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:32:43 +0200 Subject: [PATCH 37/96] Add managed upload retry exhaustion proof --- .../client/GeneratedTusProtocolContract.java | 34 ++++- .../TestGeneratedTusManagedUploadRuntime.java | 121 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 40426201..af975e5c 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1149,6 +1149,7 @@ final class GeneratedTusProtocolContract { new String[] { "managedUploadDurableRetry", "managedUploadPermanentFailure", + "managedUploadRetryPolicyExhausted", "managedUploadNetworkConstraint", }; @@ -1214,6 +1215,37 @@ final class GeneratedTusProtocolContract { "react-native", } ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadRetryPolicyExhausted", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( "managedUpload", "feature-over-protocol", diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index aaf02bfa..c8324f5a 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -238,6 +238,127 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadRetryPolicyExhausted", + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "filesystem" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadTerminal( + "failed", + "retry-policy-exhausted" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-after-permanent-failure", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed", + }, + new int[] { + 0, + 0, + } + ), + new GeneratedTusManagedUploadInput( + "hello retries!", + 7, + "managed-retry-exhausted-fingerprint", + "managed-retry-exhausted", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-retry-exhausted.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 2, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { From 2d7600d1d4592ba97213f1c5fe5315b41ffd3bcc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:53:29 +0200 Subject: [PATCH 38/96] Add generated managed source unavailable proof --- .../client/GeneratedTusProtocolContract.java | 32 +++- .../TestGeneratedTusManagedUploadRuntime.java | 165 ++++++++++++++++-- 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index af975e5c..7b7b06c7 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1150,6 +1150,7 @@ final class GeneratedTusProtocolContract { "managedUploadDurableRetry", "managedUploadPermanentFailure", "managedUploadRetryPolicyExhausted", + "managedUploadSourceUnavailable", "managedUploadNetworkConstraint", }; @@ -1246,6 +1247,35 @@ final class GeneratedTusProtocolContract { "react-native", } ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadSourceUnavailable", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( "managedUpload", "feature-over-protocol", diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index c8324f5a..65ed0fa3 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -43,6 +43,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "java", "process-lifetime-worker-pool", "copy-to-owned-storage", + "available", "filesystem" ), new GeneratedTusManagedUploadTransport( @@ -178,6 +179,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "java", "process-lifetime-worker-pool", "copy-to-owned-storage", + "available", "filesystem" ), new GeneratedTusManagedUploadTransport( @@ -244,6 +246,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "java", "process-lifetime-worker-pool", "copy-to-owned-storage", + "available", "filesystem" ), new GeneratedTusManagedUploadTransport( @@ -359,6 +362,61 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadSourceUnavailable", + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "missing-before-durable-copy", + "filesystem" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadTerminal( + "failed", + "source-unavailable" + ), + new GeneratedTusManagedUploadCleanup( + "absent-after-source-unavailable", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello missing!", + 7, + "managed-source-unavailable-fingerprint", + "managed-source-unavailable", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-source-unavailable.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "before-protocol-request", + "source-unavailable", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + + } + ), + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { @@ -383,7 +441,6 @@ public void testManagedUploadDurableRetryRuntime() throws Exception { File source = writeSourceFile(testCase); File ownedSource = ownedSourceFile(testCase, source); File stateFile = stateFile(testCase, source); - copyDurableSource(testCase, source, ownedSource); recordState(testCase, states, stateFile, "pending"); final GeneratedTusManagedUploadUrlStore urlStore = new GeneratedTusManagedUploadUrlStore(); @@ -392,18 +449,26 @@ public void testManagedUploadDurableRetryRuntime() throws Exception { client.enableResuming(urlStore); client.enableRemoveFingerprintOnSuccess(); - TusExecutor executor = managedExecutorFor(testCase, client, ownedSource, states, stateFile); - ExecutorService worker = Executors.newSingleThreadExecutor(); try { - Future future = worker.submit(new Callable() { - @Override - public Boolean call() throws Exception { - return executor.makeAttempts(); - } - }); - assertTerminalResult(testCase, future); - } finally { - worker.shutdownNow(); + prepareSourceBeforeProtocol(testCase, source, ownedSource, states, stateFile); + TusExecutor executor = managedExecutorFor(testCase, client, ownedSource, states, stateFile); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future future = worker.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTerminalResult(testCase, future); + } finally { + worker.shutdownNow(); + } + } catch (IOException error) { + if (!isSourceUnavailableBeforeProtocol(testCase)) { + throw error; + } + assertTerminalFailure(testCase, error); } cleanupAfterTerminalState(testCase, ownedSource); @@ -419,8 +484,8 @@ public Boolean call() throws Exception { .toArray(new String[testCase.expectedStates.length])); assertResumeUrlState(testCase, urlStore); assertOwnedSourceState(testCase, ownedSource); - assertTrue(testCase.scenarioId, source.exists()); - source.delete(); + assertInputSourceState(testCase, source); + assertProtocolRequestCount(testCase); stateFile.delete(); } } @@ -560,6 +625,42 @@ private void copyDurableSource( assertTrue(testCase.scenarioId, ownedSource.exists()); } + private void prepareSourceBeforeProtocol( + GeneratedTusManagedUploadRuntimeCase testCase, + File source, + File ownedSource, + List states, + File stateFile) throws IOException { + if ("available".equals(testCase.sourceAvailability)) { + copyDurableSource(testCase, source, ownedSource); + return; + } + if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; + if (source.exists() && !source.delete()) { + throw new IOException("Could not remove generated input source " + source); + } + recordState(testCase, states, stateFile, "running"); + try { + copyDurableSource(testCase, source, ownedSource); + } catch (IOException error) { + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + throw error; + } + throw new AssertionError(testCase.scenarioId + " unexpectedly prepared missing source"); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source availability " + + testCase.sourceAvailability); + } + + private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return "source-unavailable".equals(testCase.terminalFailure) + && "missing-before-durable-copy".equals(testCase.sourceAvailability); + } + private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { @@ -582,6 +683,10 @@ private void assertOwnedSourceState( ownedSource.delete(); return; } + if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { + assertFalse(testCase.scenarioId, ownedSource.exists()); + return; + } throw new AssertionError( testCase.scenarioId @@ -589,6 +694,18 @@ private void assertOwnedSourceState( + testCase.ownedSourceCleanup); } + private void assertInputSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + assertFalse(testCase.scenarioId, source.exists()); + return; + } + + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); + } + private void assertResumeUrlState( GeneratedTusManagedUploadRuntimeCase testCase, GeneratedTusManagedUploadUrlStore urlStore) { @@ -605,6 +722,21 @@ private void assertResumeUrlState( + testCase.resumeUrlCleanup); } + private void assertProtocolRequestCount(GeneratedTusManagedUploadRuntimeCase testCase) { + HttpRequest[] requests = mockServer.retrieveRecordedRequests(new HttpRequest()); + assertTrue( + testCase.scenarioId, + requests.length == expectedProtocolRequestCount(testCase)); + } + + private int expectedProtocolRequestCount(GeneratedTusManagedUploadRuntimeCase testCase) { + int count = 0; + for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { + count += attempt.requests.length; + } + return count; + } + private void recordState( GeneratedTusManagedUploadRuntimeCase testCase, List states, @@ -732,6 +864,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String runtime; final String scheduler; final String sourceDurability; + final String sourceAvailability; final String stateBackend; final String locationHeaderName; final String terminalState; @@ -756,6 +889,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.runtime = profile.runtime; this.scheduler = profile.scheduler; this.sourceDurability = profile.sourceDurability; + this.sourceAvailability = profile.sourceAvailability; this.stateBackend = profile.stateBackend; this.locationHeaderName = transport.locationHeaderName; this.terminalState = terminal.state; @@ -785,6 +919,7 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { final String runtime; final String scheduler; final String sourceDurability; + final String sourceAvailability; final String stateBackend; GeneratedTusManagedUploadRuntimeProfile( @@ -792,11 +927,13 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { String runtime, String scheduler, String sourceDurability, + String sourceAvailability, String stateBackend) { this.scenarioId = scenarioId; this.runtime = runtime; this.scheduler = scheduler; this.sourceDurability = sourceDurability; + this.sourceAvailability = sourceAvailability; this.stateBackend = stateBackend; } } From 729c107adc84f75a69953478866700707aa25726 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 14:22:41 +0200 Subject: [PATCH 39/96] Add generated managed network deferral proof --- .../client/GeneratedTusProtocolContract.java | 7 +- .../TestGeneratedTusManagedUploadRuntime.java | 187 ++++++++++++++---- 2 files changed, 153 insertions(+), 41 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 7b7b06c7..cba28960 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1280,9 +1280,12 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadNetworkConstraint", - new String[0], + new String[] { + "android", + }, new String[] { "accept-upload-submission", + "make-source-durable", "schedule-upload-work", "publish-upload-state", }, diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 65ed0fa3..ddb01db0 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -44,13 +44,20 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "process-lifetime-worker-pool", "copy-to-owned-storage", "available", - "filesystem" + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "succeeded", + "", "" ), new GeneratedTusManagedUploadCleanup( @@ -180,14 +187,21 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "process-lifetime-worker-pool", "copy-to-owned-storage", "available", - "filesystem" + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "failed", - "unretryable-protocol-error" + "unretryable-protocol-error", + "" ), new GeneratedTusManagedUploadCleanup( "retain-owned-source-after-permanent-failure", @@ -247,14 +261,21 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "process-lifetime-worker-pool", "copy-to-owned-storage", "available", - "filesystem" + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "failed", - "retry-policy-exhausted" + "retry-policy-exhausted", + "" ), new GeneratedTusManagedUploadCleanup( "retain-owned-source-after-permanent-failure", @@ -369,14 +390,21 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "process-lifetime-worker-pool", "copy-to-owned-storage", "missing-before-durable-copy", - "filesystem" + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "failed", - "source-unavailable" + "source-unavailable", + "" ), new GeneratedTusManagedUploadCleanup( "absent-after-source-unavailable", @@ -451,18 +479,23 @@ public void testManagedUploadDurableRetryRuntime() throws Exception { try { prepareSourceBeforeProtocol(testCase, source, ownedSource, states, stateFile); - TusExecutor executor = managedExecutorFor(testCase, client, ownedSource, states, stateFile); - ExecutorService worker = Executors.newSingleThreadExecutor(); - try { - Future future = worker.submit(new Callable() { - @Override - public Boolean call() throws Exception { - return executor.makeAttempts(); - } - }); - assertTerminalResult(testCase, future); - } finally { - worker.shutdownNow(); + if (shouldDeferBeforeProtocol(testCase)) { + assertDeferredResult(testCase); + } else { + TusExecutor executor = + managedExecutorFor(testCase, client, ownedSource, states, stateFile); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future future = worker.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTerminalResult(testCase, future); + } finally { + worker.shutdownNow(); + } } } catch (IOException error) { if (!isSourceUnavailableBeforeProtocol(testCase)) { @@ -493,14 +526,18 @@ public Boolean call() throws Exception { private void assertTerminalResult( GeneratedTusManagedUploadRuntimeCase testCase, Future future) throws Exception { + if (!"terminal".equals(testCase.outcomeKind)) { + throw new AssertionError(testCase.scenarioId + " expected deferred outcome"); + } + try { boolean result = future.get(); - if (!"succeeded".equals(testCase.terminalState)) { + if (!"succeeded".equals(testCase.outcomeState)) { throw new AssertionError(testCase.scenarioId + " expected terminal failure"); } assertTrue(testCase.scenarioId, result); } catch (ExecutionException error) { - if (!"failed".equals(testCase.terminalState)) { + if (!"failed".equals(testCase.outcomeState)) { throw error; } assertTerminalFailure(testCase, error.getCause()); @@ -510,15 +547,15 @@ private void assertTerminalResult( private void assertTerminalFailure( GeneratedTusManagedUploadRuntimeCase testCase, Throwable error) { - if ("unretryable-protocol-error".equals(testCase.terminalFailure)) { + if ("unretryable-protocol-error".equals(testCase.outcomeFailure)) { assertTrue(testCase.scenarioId, error instanceof ProtocolException); return; } - if ("source-unavailable".equals(testCase.terminalFailure)) { + if ("source-unavailable".equals(testCase.outcomeFailure)) { assertTrue(testCase.scenarioId, error instanceof IOException); return; } - if ("retry-policy-exhausted".equals(testCase.terminalFailure)) { + if ("retry-policy-exhausted".equals(testCase.outcomeFailure)) { assertTrue( testCase.scenarioId, error instanceof ProtocolException || error instanceof IOException); @@ -528,7 +565,36 @@ private void assertTerminalFailure( throw new AssertionError( testCase.scenarioId + " uses unsupported generated terminal failure " - + testCase.terminalFailure); + + testCase.outcomeFailure); + } + + private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { + if ( + !"deferred".equals(testCase.outcomeKind) + || !"pending".equals(testCase.outcomeState) + || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) + || !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) + || networkConstraintSatisfied(testCase)) { + throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); + } + } + + private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { + if ("offline".equals(testCase.currentNetwork)) { + return false; + } + if ("any-network".equals(testCase.networkRequired)) { + return "metered-network".equals(testCase.currentNetwork) + || "unmetered-network".equals(testCase.currentNetwork); + } + if ("unmetered-network".equals(testCase.networkRequired)) { + return "unmetered-network".equals(testCase.currentNetwork); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated network requirement " + + testCase.networkRequired); } private TusExecutor managedExecutorFor( @@ -657,10 +723,14 @@ private void prepareSourceBeforeProtocol( } private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { - return "source-unavailable".equals(testCase.terminalFailure) + return "source-unavailable".equals(testCase.outcomeFailure) && "missing-before-durable-copy".equals(testCase.sourceAvailability); } + private boolean shouldDeferBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return "defer-until-network-constraint-satisfied".equals(testCase.networkDecision); + } + private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { @@ -683,6 +753,11 @@ private void assertOwnedSourceState( ownedSource.delete(); return; } + if ("retain-owned-source-while-deferred".equals(testCase.ownedSourceCleanup)) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { assertFalse(testCase.scenarioId, ownedSource.exists()); return; @@ -711,7 +786,8 @@ private void assertResumeUrlState( GeneratedTusManagedUploadUrlStore urlStore) { if ( "remove-after-success".equals(testCase.resumeUrlCleanup) - || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup)) { + || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup) + || "absent-while-deferred".equals(testCase.resumeUrlCleanup)) { assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); return; } @@ -866,9 +942,14 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String sourceDurability; final String sourceAvailability; final String stateBackend; + final String networkRequired; + final String currentNetwork; + final String networkDecision; final String locationHeaderName; - final String terminalState; - final String terminalFailure; + final String outcomeKind; + final String outcomeState; + final String outcomeFailure; + final String outcomeReason; final String ownedSourceCleanup; final String resumeUrlCleanup; final String[] expectedStates; @@ -880,7 +961,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadTransport transport, - GeneratedTusManagedUploadTerminal terminal, + GeneratedTusManagedUploadOutcome outcome, GeneratedTusManagedUploadCleanup cleanup, GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, @@ -891,9 +972,14 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.sourceDurability = profile.sourceDurability; this.sourceAvailability = profile.sourceAvailability; this.stateBackend = profile.stateBackend; + this.networkRequired = profile.networkRequired; + this.currentNetwork = profile.currentNetwork; + this.networkDecision = profile.networkDecision; this.locationHeaderName = transport.locationHeaderName; - this.terminalState = terminal.state; - this.terminalFailure = terminal.failure; + this.outcomeKind = outcome.kind; + this.outcomeState = outcome.state; + this.outcomeFailure = outcome.failure; + this.outcomeReason = outcome.reason; this.ownedSourceCleanup = cleanup.ownedSource; this.resumeUrlCleanup = cleanup.resumeUrl; this.expectedStates = retryPlan.expectedStates; @@ -904,13 +990,17 @@ private static final class GeneratedTusManagedUploadRuntimeCase { } } - private static final class GeneratedTusManagedUploadTerminal { + private static final class GeneratedTusManagedUploadOutcome { + final String kind; final String state; final String failure; + final String reason; - GeneratedTusManagedUploadTerminal(String state, String failure) { + GeneratedTusManagedUploadOutcome(String kind, String state, String failure, String reason) { + this.kind = kind; this.state = state; this.failure = failure; + this.reason = reason; } } @@ -921,6 +1011,9 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { final String sourceDurability; final String sourceAvailability; final String stateBackend; + final String networkRequired; + final String currentNetwork; + final String networkDecision; GeneratedTusManagedUploadRuntimeProfile( String scenarioId, @@ -928,13 +1021,29 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { String scheduler, String sourceDurability, String sourceAvailability, - String stateBackend) { + String stateBackend, + GeneratedTusManagedUploadNetwork network) { this.scenarioId = scenarioId; this.runtime = runtime; this.scheduler = scheduler; this.sourceDurability = sourceDurability; this.sourceAvailability = sourceAvailability; this.stateBackend = stateBackend; + this.networkRequired = network.required; + this.currentNetwork = network.current; + this.networkDecision = network.decision; + } + } + + private static final class GeneratedTusManagedUploadNetwork { + final String required; + final String current; + final String decision; + + GeneratedTusManagedUploadNetwork(String required, String current, String decision) { + this.required = required; + this.current = current; + this.decision = decision; } } From a165b6bdf13adfa2ae14f4dd032b9d129754a0d9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 21:59:29 +0200 Subject: [PATCH 40/96] Add devdock TUS upload example --- example/build.gradle | 6 + .../java/example/Api2DevdockTusUpload.java | 175 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java diff --git a/example/build.gradle b/example/build.gradle index aeeb70b7..87b0c476 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -2,5 +2,11 @@ apply plugin: 'java' dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.json:json:20240303' implementation rootProject } + +tasks.register('api2DevdockTusUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusUpload' +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java new file mode 100644 index 00000000..44d7254b --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java @@ -0,0 +1,175 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusUpload { + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = loadScenario(); + final JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final String uploadUrl = uploadWithTus(scenario, createResponse); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " uploaded to " + + uploadUrl + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject loadScenario() throws IOException { + String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath == null || scenarioPath.isEmpty()) { + scenarioPath = "example/api2-scenario.json"; + } + + final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + private static String uploadWithTus( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final Object endpointValue = resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse); + final byte[] content = scenarioBytes(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(new URL(scalarString(endpointValue))); + client.enableResuming(new TusURLMemoryStore()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-devdock-example"); + upload.setMetadata(uploadMetadata(uploadConfig, scenario, createResponse)); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + while (uploader.uploadChunk() > -1) { + // Continue until the client reports that the source is fully uploaded. + } + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "remote offset " + uploader.getOffset() + ", expected " + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("upload did not return a URL"); + } + + return uploader.getUploadURL().toString(); + } + + private static byte[] scenarioBytes(JSONObject uploadConfig) { + final JSONObject source = uploadConfig.getJSONObject("source"); + final String kind = source.getString("kind"); + if (!"bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported source kind " + kind); + } + + final String encoding = source.getString("encoding"); + if (!"utf8".equals(encoding)) { + throw new IllegalArgumentException("unsupported source encoding " + encoding); + } + + return source.getString("value").getBytes(StandardCharsets.UTF_8); + } + + private static Map uploadMetadata( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) { + final JSONArray fields = uploadConfig.getJSONArray("metadata"); + final Map metadata = new LinkedHashMap(); + for (int index = 0; index < fields.length(); index++) { + final JSONObject field = fields.getJSONObject(index); + metadata.put( + field.getString("name"), + scalarString(resolveValue(field.getJSONObject("value"), scenario, createResponse)) + ); + } + + return metadata; + } + + private static Object resolveValue( + JSONObject valueSpec, + JSONObject scenario, + JSONObject createResponse + ) { + if (valueSpec.has("value")) { + return valueSpec.get("value"); + } + + final JSONObject source = valueSpec.getJSONObject("source"); + final String root = source.getString("root"); + final Object rootValue; + if ("scenario".equals(root)) { + rootValue = scenario; + } else if ("createResponse".equals(root)) { + rootValue = createResponse; + } else { + throw new IllegalArgumentException("unsupported scenario value root " + root); + } + + return readPath(rootValue, source.getJSONArray("path")); + } + + private static Object readPath(Object value, JSONArray pathParts) { + Object current = value; + for (int index = 0; index < pathParts.length(); index++) { + final Object part = pathParts.get(index); + if (current instanceof JSONObject && part instanceof String) { + current = ((JSONObject) current).get((String) part); + continue; + } + + if (current instanceof JSONArray && part instanceof Number) { + current = ((JSONArray) current).get(((Number) part).intValue()); + continue; + } + + throw new IllegalArgumentException("cannot read scenario path part " + part); + } + + return current; + } + + private static String scalarString(Object value) { + if (JSONObject.NULL.equals(value)) { + return "null"; + } + + return String.valueOf(value); + } + + private Api2DevdockTusUpload() { + throw new IllegalStateException("Utility class"); + } +} From bd7aaa1a082b0989cc74dac73919019260f6523b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:08:35 +0200 Subject: [PATCH 41/96] Run devdock example from repo root --- example/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/example/build.gradle b/example/build.gradle index 87b0c476..558931d9 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -9,4 +9,5 @@ dependencies { tasks.register('api2DevdockTusUpload', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusUpload' + workingDir = rootProject.projectDir } From f444876412b73c4b47ebefc8acc0f1dc49c5ae19 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:14:40 +0200 Subject: [PATCH 42/96] Satisfy lint for devdock example --- .../io/tus/java/example/Api2DevdockTusUpload.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java index 44d7254b..fa5d2128 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java @@ -18,6 +18,11 @@ import java.util.Map; public final class Api2DevdockTusUpload { + /** + * Run the API2 devdock TUS upload example. + * + * @param args ignored + */ public static void main(String[] args) { try { System.setProperty("http.strictPostRedirect", "true"); @@ -68,9 +73,10 @@ private static String uploadWithTus( final TusUploader uploader = client.resumeOrCreateUpload(upload); uploader.setChunkSize(content.length); - while (uploader.uploadChunk() > -1) { - // Continue until the client reports that the source is fully uploaded. - } + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); uploader.finish(); if (uploader.getOffset() != content.length) { From 9108bda356c58883a5e2e30886383939e14b6876 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:38:18 +0200 Subject: [PATCH 43/96] Emit devdock example result --- .../io/tus/java/example/Api2DevdockTusUpload.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java index fa5d2128..33845e69 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java @@ -30,6 +30,7 @@ public static void main(String[] args) { final JSONObject scenario = loadScenario(); final JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse"); final String uploadUrl = uploadWithTus(scenario, createResponse); + writeResult(uploadUrl); System.out.println( "Java TUS SDK devdock scenario " @@ -53,6 +54,20 @@ private static JSONObject loadScenario() throws IOException { return new JSONObject(new String(contents, StandardCharsets.UTF_8)); } + private static void writeResult(String uploadUrl) throws IOException { + final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + final JSONObject result = new JSONObject(); + result.put("uploadUrl", uploadUrl); + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) + ); + } + private static String uploadWithTus( JSONObject scenario, JSONObject createResponse From eea6b25aaade03df97d90cd428e4776df0c40fa7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:10:01 +0200 Subject: [PATCH 44/96] Regenerate TUS runtime event proofs --- .../TestGeneratedTusManagedUploadRuntime.java | 79 ++++++++++++++- .../client/TestGeneratedTusRuntimeEvents.java | 95 ++++++++++++++++++- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index ddb01db0..dd482b54 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -108,12 +108,24 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC50eHQ=" + ), }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Location", "https://tus.io/uploads/managed-durable-retry" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusManagedUploadRequest( @@ -126,12 +138,24 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), } @@ -146,7 +170,12 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "upload", 0, 200, - new GeneratedTusManagedUploadHeader[0], + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", @@ -156,6 +185,10 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusManagedUploadRequest( @@ -168,12 +201,24 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), } @@ -247,6 +292,14 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" + ), }, new GeneratedTusManagedUploadHeader[0] ), @@ -328,6 +381,14 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), }, new GeneratedTusManagedUploadHeader[0] ), @@ -352,6 +413,14 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), }, new GeneratedTusManagedUploadHeader[0] ), @@ -376,6 +445,14 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), }, new GeneratedTusManagedUploadHeader[0] ), diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 2dbcd7be..08173bfe 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -57,12 +57,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Length", "11" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", "https://tus.io/uploads/generated-contract" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusRuntimeEventRequest( @@ -74,12 +86,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), }, @@ -117,7 +141,12 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "HEAD", "upload", 200, - new GeneratedTusRuntimeEventHeader[0], + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), + }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Length", @@ -127,6 +156,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "5" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusRuntimeEventRequest( @@ -138,12 +171,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "5" ), + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), }, @@ -182,12 +227,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Length", "11" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", "relative-contract" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusRuntimeEventRequest( @@ -199,12 +256,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), }, @@ -243,12 +312,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Defer-Length", "1" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", "https://tus.io/uploads/deferred-contract" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusRuntimeEventRequest( @@ -264,12 +345,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), + new GeneratedTusRuntimeEventHeader( + "Tus-Resumable", + "1.0.0" + ), } ), }, From 0cf5e189542a011dd3e2e6928c6ba62bc6e3809e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:41:44 +0200 Subject: [PATCH 45/96] Apply generated TUS default headers --- .../java/io/tus/java/client/TusClient.java | 8 +++++--- .../java/io/tus/java/client/TusProtocol.java | 18 ++++++++++++++++++ .../TestGeneratedTusProtocolContract.java | 18 +++++++++++++++++- .../java/io/tus/java/client/TestTusClient.java | 4 +++- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index d4df183f..8f7eab19 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -351,8 +351,8 @@ public TusUploader resumeOrCreateUpload(@NotNull TusUpload upload) throws Protoc } /** - * Set headers used for every HTTP request. Currently, this will add the Tus-Resumable header - * and any custom header which can be configured using {@link #setHeaders(Map)}, + * Set headers used for every HTTP request. Currently, this will add generated protocol default + * headers and any custom header which can be configured using {@link #setHeaders(Map)}, * * @param connection The connection whose headers will be modified. */ @@ -367,7 +367,9 @@ public void prepareConnection(@NotNull HttpURLConnection connection) { connection.setInstanceFollowRedirects(Boolean.getBoolean("http.strictPostRedirect")); connection.setConnectTimeout(connectTimeout); - connection.addRequestProperty("Tus-Resumable", TUS_VERSION); + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + connection.addRequestProperty(entry.getKey(), entry.getValue()); + } if (headers != null) { for (Map.Entry entry : headers.entrySet()) { diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index b4a53891..06de842a 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -6,12 +6,30 @@ package io.tus.java.client; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + /** * Generated TUS protocol constants used by the runtime client. */ final class TusProtocol { static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; + static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); + static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); private TusProtocol() { } + + private static Map defaultRequestHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + private static Map defaultResponseHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java index 6d57686e..22e98fb4 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -1,5 +1,7 @@ package io.tus.java.client; +import java.util.Map; + import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -27,9 +29,14 @@ public void testDefaultProtocolVersionMatchesRuntimeConstant() { } assertEquals(1, defaultCount); - assertEquals("1.0.0", generatedDefault); assertEquals(generatedDefault, TusProtocol.DEFAULT_PROTOCOL_VERSION); assertEquals(generatedDefault, TusClient.TUS_VERSION); + assertEquals( + generatedDefault, + onlyGeneratedProtocolHeader(TusProtocol.DEFAULT_REQUEST_HEADERS)); + assertEquals( + generatedDefault, + onlyGeneratedProtocolHeader(TusProtocol.DEFAULT_RESPONSE_HEADERS)); } /** @@ -82,6 +89,15 @@ private static GeneratedTusProtocolContract.GeneratedTusProtocolOperation findOp throw new AssertionError("Missing generated TUS operation: " + operationId); } + private static String onlyGeneratedProtocolHeader(Map headers) { + assertEquals(1, headers.size()); + for (Map.Entry entry : headers.entrySet()) { + return entry.getValue(); + } + + throw new AssertionError("Generated protocol header map was empty"); + } + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( String featureId) { for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index 33211f2b..96de1686 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -441,7 +441,9 @@ public void testPrepareConnection() throws IOException { TusClient client = new TusClient(); client.prepareConnection(connection); - assertEquals(connection.getRequestProperty("Tus-Resumable"), TusClient.TUS_VERSION); + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + assertEquals(entry.getValue(), connection.getRequestProperty(entry.getKey())); + } } /** From eaf8ea45e804eef1cfbccc0118ad4f0887d386a7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:20:52 +0200 Subject: [PATCH 46/96] Use generated protocol headers in Java tests --- .../tus/java/client/MockServerProvider.java | 17 +++ .../io/tus/java/client/TestTusClient.java | 125 +++++++----------- .../io/tus/java/client/TestTusUploader.java | 78 +++++------ 3 files changed, 99 insertions(+), 121 deletions(-) diff --git a/src/test/java/io/tus/java/client/MockServerProvider.java b/src/test/java/io/tus/java/client/MockServerProvider.java index 791b9a31..c02df53a 100644 --- a/src/test/java/io/tus/java/client/MockServerProvider.java +++ b/src/test/java/io/tus/java/client/MockServerProvider.java @@ -3,9 +3,12 @@ import org.junit.After; import org.junit.Before; import org.mockserver.client.MockServerClient; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; import org.mockserver.socket.PortFactory; import java.net.URL; +import java.util.Map; import static org.mockserver.integration.ClientAndServer.startClientAndServer; @@ -36,4 +39,18 @@ public void setUp() throws Exception { public void tearDown() { mockServer.stop(); } + + protected HttpRequest withDefaultProtocolRequestHeaders(HttpRequest request) { + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + request.withHeader(entry.getKey(), entry.getValue()); + } + return request; + } + + protected HttpResponse withDefaultProtocolResponseHeaders(HttpResponse response) { + for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { + response.withHeader(entry.getKey(), entry.getValue()); + } + return response; + } } diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index 96de1686..e0a723ce 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -81,17 +81,15 @@ public void testEnableResuming() { */ @Test public void testCreateUpload() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Metadata", "foo aGVsbG8=,bar d29ybGQ=") - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); Map metadata = new LinkedHashMap(); metadata.put("foo", "hello"); @@ -115,15 +113,13 @@ public void testCreateUpload() throws IOException, ProtocolException { */ @Test public void testCreateUploadWithDeferredLength() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Defer-Length", "1")) - .respond(new HttpResponse() + .withHeader("Upload-Defer-Length", "1"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -148,17 +144,15 @@ public void testCreateUploadWithDeferredLength() throws IOException, ProtocolExc */ @Test public void testCreateUploadWithProxy() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Proxy-Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Metadata", "foo aGVsbG8=,bar d29ybGQ=") - .withHeader("Upload-Length", "11")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "11"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); Map metadata = new LinkedHashMap(); metadata.put("foo", "hello"); @@ -182,14 +176,12 @@ public void testCreateUploadWithProxy() throws IOException, ProtocolException { */ @Test public void testCreateUploadWithMissingLocationHeader() throws Exception { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() - .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)); + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -214,24 +206,21 @@ public void testCreateUploadWithRelativeLocation() throws Exception { System.setProperty("http.strictPostRedirect", "true"); // Attempt a real redirect - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirect") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) + .withHeader("Upload-Length", "10"))) .respond(new HttpResponse() .withStatusCode(301) .withHeader("Location", mockServerURL + "Redirected/")); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirected/") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", "foo")); + .withHeader("Location", "foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(new URL(mockServerURL + "Redirect")); @@ -256,14 +245,12 @@ public void testCreateUploadWithRelativeLocation() throws Exception { @Test public void testResumeUpload() throws ResumingNotEnabledException, FingerprintNotFoundException, IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("HEAD") - .withPath("/files/foo") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)) - .respond(new HttpResponse() + .withPath("/files/foo"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "3")); + .withHeader("Upload-Offset", "3"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -309,16 +296,14 @@ public void remove(String fingerprint) { */ @Test public void testResumeOrCreateUpload() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -338,16 +323,14 @@ public void testResumeOrCreateUpload() throws IOException, ProtocolException { */ @Test public void testResumeOrCreateUploadWithProxy() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Proxy-Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "11")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "11"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -369,22 +352,19 @@ public void testResumeOrCreateUploadWithProxy() throws IOException, ProtocolExce */ @Test public void testResumeOrCreateUploadNotFound() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("HEAD") - .withPath("/files/not_found") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)) + .withPath("/files/not_found"))) .respond(new HttpResponse() .withStatusCode(404)); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -409,14 +389,12 @@ public void testResumeOrCreateUploadNotFound() throws IOException, ProtocolExcep */ @Test public void testBeginOrResumeUploadFromURL() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("HEAD") - .withPath("/files/fooFromURL") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)) - .respond(new HttpResponse() + .withPath("/files/fooFromURL"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "3")); + .withHeader("Upload-Offset", "3"))); TusClient client = new TusClient(); URL uploadURL = new URL(mockServerURL.toString() + "/fooFromURL"); @@ -507,24 +485,21 @@ public void testFollowRedirects() throws Exception { assertTrue(connection.getInstanceFollowRedirects()); // Attempt a real redirect - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirect") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) + .withHeader("Upload-Length", "10"))) .respond(new HttpResponse() .withStatusCode(301) .withHeader("Location", mockServerURL + "Redirected")); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirected") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); client.setUploadCreationURL(new URL(mockServerURL + "Redirect")); TusUpload upload = new TusUpload(); diff --git a/src/test/java/io/tus/java/client/TestTusUploader.java b/src/test/java/io/tus/java/client/TestTusUploader.java index b1a7b70c..9871c0ce 100644 --- a/src/test/java/io/tus/java/client/TestTusUploader.java +++ b/src/test/java/io/tus/java/client/TestTusUploader.java @@ -42,17 +42,15 @@ public class TestTusUploader extends MockServerProvider { public void testTusUploader() throws IOException, ProtocolException { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/foo") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "3") .withHeader("Content-Type", "application/offset+octet-stream") .withHeader("Connection", "keep-alive") - .withBody(Arrays.copyOfRange(content, 3, 11))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 3, 11)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "11")); + .withHeader("Upload-Offset", "11"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/foo"); @@ -83,17 +81,15 @@ public void testTusUploader() throws IOException, ProtocolException { public void testTusUploaderDeclaresDeferredLength() throws IOException, ProtocolException { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/deferred") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Length", "11") .withHeader("Upload-Offset", "0") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(content)) - .respond(new HttpResponse() + .withBody(content))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "11")); + .withHeader("Upload-Offset", "11"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/deferred"); @@ -119,17 +115,15 @@ public void testTusUploaderDeclaresDeferredLength() throws IOException, Protocol public void testTusUploaderWithProxy() throws IOException, ProtocolException { byte[] content = "hello world with proxy".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/foo") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "0") .withHeader("Content-Type", "application/offset+octet-stream") .withHeader("Proxy-Connection", "keep-alive") - .withBody(Arrays.copyOf(content, content.length))) - .respond(new HttpResponse() + .withBody(Arrays.copyOf(content, content.length)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "22")); + .withHeader("Upload-Offset", "22"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/foo"); @@ -283,38 +277,32 @@ public URL getURL() { public void testSetRequestPayloadSize() throws Exception { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/payload") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "0") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(Arrays.copyOfRange(content, 0, 5))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 0, 5)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "5")); + .withHeader("Upload-Offset", "5"))); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/payload") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "5") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(Arrays.copyOfRange(content, 5, 10))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 5, 10)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "10")); + .withHeader("Upload-Offset", "10"))); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/payload") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "10") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(Arrays.copyOfRange(content, 10, 11))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 10, 11)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "11")); + .withHeader("Upload-Offset", "11"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/payload"); @@ -374,11 +362,10 @@ public void testSetRequestPayloadSizeThrows() throws Exception { public void testMissingUploadOffsetHeader() throws Exception { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() - .withPath("/files/missingHeader")) - .respond(new HttpResponse() - .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)); + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/missingHeader"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/missingHeader"); @@ -408,12 +395,11 @@ public void testMissingUploadOffsetHeader() throws Exception { public void testUnmatchingUploadOffsetHeader() throws Exception { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() - .withPath("/files/unmatchingHeader")) - .respond(new HttpResponse() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/unmatchingHeader"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "44")); + .withHeader("Upload-Offset", "44"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/unmatchingHeader"); From 845bf4e83033165773955d98c6f8bcd9ef7ce1f3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:26:26 +0200 Subject: [PATCH 47/96] Mark Java protocol test helpers final --- src/test/java/io/tus/java/client/MockServerProvider.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/MockServerProvider.java b/src/test/java/io/tus/java/client/MockServerProvider.java index c02df53a..ec70d90a 100644 --- a/src/test/java/io/tus/java/client/MockServerProvider.java +++ b/src/test/java/io/tus/java/client/MockServerProvider.java @@ -40,14 +40,14 @@ public void tearDown() { mockServer.stop(); } - protected HttpRequest withDefaultProtocolRequestHeaders(HttpRequest request) { + protected final HttpRequest withDefaultProtocolRequestHeaders(HttpRequest request) { for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { request.withHeader(entry.getKey(), entry.getValue()); } return request; } - protected HttpResponse withDefaultProtocolResponseHeaders(HttpResponse response) { + protected final HttpResponse withDefaultProtocolResponseHeaders(HttpResponse response) { for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { response.withHeader(entry.getKey(), entry.getValue()); } From 11ef5726b2a7b1c29101a1af7e394e8b8d39e5d1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:32:08 +0200 Subject: [PATCH 48/96] Regenerate Java TUS default header fixtures --- .../TestGeneratedTusManagedUploadRuntime.java | 83 +++++++--------- .../client/TestGeneratedTusRuntimeEvents.java | 99 +++++++------------ 2 files changed, 66 insertions(+), 116 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index dd482b54..6097130c 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -103,29 +103,23 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 201, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", "14" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusManagedUploadHeader( "Upload-Metadata", "filename bWFuYWdlZC50eHQ=" ), }, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Location", "https://tus.io/uploads/managed-durable-retry" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), } ), new GeneratedTusManagedUploadRequest( @@ -133,6 +127,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "upload", 7, 204, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", @@ -142,20 +137,13 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), }, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", "7" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), } ), } @@ -170,12 +158,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "upload", 0, 200, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - }, + true, + new GeneratedTusManagedUploadHeader[0], + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", @@ -185,10 +170,6 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Offset", "7" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), } ), new GeneratedTusManagedUploadRequest( @@ -196,6 +177,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "upload", 7, 204, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", @@ -205,20 +187,13 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), }, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", "14" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), } ), } @@ -287,20 +262,18 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 400, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", "14" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusManagedUploadHeader( "Upload-Metadata", "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" ), }, + false, new GeneratedTusManagedUploadHeader[0] ), } @@ -376,20 +349,18 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 500, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", "14" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusManagedUploadHeader( "Upload-Metadata", "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" ), }, + false, new GeneratedTusManagedUploadHeader[0] ), } @@ -408,20 +379,18 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 500, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", "14" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusManagedUploadHeader( "Upload-Metadata", "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" ), }, + false, new GeneratedTusManagedUploadHeader[0] ), } @@ -440,20 +409,18 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 500, + true, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", "14" ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusManagedUploadHeader( "Upload-Metadata", "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" ), }, + false, new GeneratedTusManagedUploadHeader[0] ), } @@ -948,6 +915,11 @@ private HttpRequest requestFor( HttpRequest httpRequest = new HttpRequest() .withMethod(method) .withPath(pathFor(testCase, request)); + if (request.includesDefaultProtocolRequestHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + httpRequest.withHeader(entry.getKey(), entry.getValue()); + } + } for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { httpRequest.withHeader(header.name, header.value); } @@ -981,6 +953,11 @@ private HttpResponse responseFor( GeneratedTusManagedUploadRuntimeCase testCase, GeneratedTusManagedUploadRequest request) throws Exception { HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); + if (request.includesDefaultProtocolResponseHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { + response.withHeader(entry.getKey(), entry.getValue()); + } + } for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { response.withHeader(header.name, headerValueFor(testCase, header)); } @@ -1208,7 +1185,9 @@ private static final class GeneratedTusManagedUploadRequest { final String url; final int bodySize; final int statusCode; + final boolean includesDefaultProtocolRequestHeaders; final GeneratedTusManagedUploadHeader[] requestHeaders; + final boolean includesDefaultProtocolResponseHeaders; final GeneratedTusManagedUploadHeader[] responseHeaders; GeneratedTusManagedUploadRequest( @@ -1216,13 +1195,17 @@ private static final class GeneratedTusManagedUploadRequest { String url, int bodySize, int statusCode, + boolean includesDefaultProtocolRequestHeaders, GeneratedTusManagedUploadHeader[] requestHeaders, + boolean includesDefaultProtocolResponseHeaders, GeneratedTusManagedUploadHeader[] responseHeaders) { this.method = method; this.url = url; this.bodySize = bodySize; this.statusCode = statusCode; + this.includesDefaultProtocolRequestHeaders = includesDefaultProtocolRequestHeaders; this.requestHeaders = requestHeaders; + this.includesDefaultProtocolResponseHeaders = includesDefaultProtocolResponseHeaders; this.responseHeaders = responseHeaders; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 08173bfe..c1a93158 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -52,35 +52,30 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "POST", "endpoint", 201, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Length", "11" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusRuntimeEventHeader( "Upload-Metadata", "filename aGVsbG8udHh0" ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", "https://tus.io/uploads/generated-contract" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), new GeneratedTusRuntimeEventRequest( "PATCH", "upload", 204, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", @@ -90,20 +85,13 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), }, @@ -141,12 +129,9 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "HEAD", "upload", 200, - new GeneratedTusRuntimeEventHeader[] { - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), - }, + true, + new GeneratedTusRuntimeEventHeader[0], + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Length", @@ -156,16 +141,13 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "5" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), new GeneratedTusRuntimeEventRequest( "PATCH", "upload", 204, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", @@ -175,20 +157,13 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), }, @@ -222,35 +197,30 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "POST", "endpoint", 201, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Length", "11" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusRuntimeEventHeader( "Upload-Metadata", "filename aGVsbG8udHh0" ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", "relative-contract" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), new GeneratedTusRuntimeEventRequest( "PATCH", "upload", 204, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", @@ -260,20 +230,13 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), }, @@ -307,35 +270,30 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "POST", "endpoint", 201, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Defer-Length", "1" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), new GeneratedTusRuntimeEventHeader( "Upload-Metadata", "filename aGVsbG8udHh0" ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Location", "https://tus.io/uploads/deferred-contract" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), new GeneratedTusRuntimeEventRequest( "PATCH", "upload", 204, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Length", @@ -349,20 +307,13 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), }, + true, new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Upload-Offset", "11" ), - new GeneratedTusRuntimeEventHeader( - "Tus-Resumable", - "1.0.0" - ), } ), }, @@ -524,6 +475,11 @@ private HttpRequest requestFor( HttpRequest httpRequest = new HttpRequest() .withMethod(method) .withPath(pathFor(testCase, request)); + if (request.includesDefaultProtocolRequestHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + httpRequest.withHeader(entry.getKey(), entry.getValue()); + } + } for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { httpRequest.withHeader(header.name, header.value); } @@ -557,6 +513,11 @@ private HttpResponse responseFor( GeneratedTusRuntimeEventCase testCase, GeneratedTusRuntimeEventRequest request) throws Exception { HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); + if (request.includesDefaultProtocolResponseHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { + response.withHeader(entry.getKey(), entry.getValue()); + } + } for (GeneratedTusRuntimeEventHeader header : request.responseHeaders) { response.withHeader(header.name, headerValueFor(testCase, header)); } @@ -766,19 +727,25 @@ private static final class GeneratedTusRuntimeEventRequest { final String method; final String url; final int statusCode; + final boolean includesDefaultProtocolRequestHeaders; final GeneratedTusRuntimeEventHeader[] requestHeaders; + final boolean includesDefaultProtocolResponseHeaders; final GeneratedTusRuntimeEventHeader[] responseHeaders; GeneratedTusRuntimeEventRequest( String method, String url, int statusCode, + boolean includesDefaultProtocolRequestHeaders, GeneratedTusRuntimeEventHeader[] requestHeaders, + boolean includesDefaultProtocolResponseHeaders, GeneratedTusRuntimeEventHeader[] responseHeaders) { this.method = method; this.url = url; this.statusCode = statusCode; + this.includesDefaultProtocolRequestHeaders = includesDefaultProtocolRequestHeaders; this.requestHeaders = requestHeaders; + this.includesDefaultProtocolResponseHeaders = includesDefaultProtocolResponseHeaders; this.responseHeaders = responseHeaders; } } From f8d9ab784a61d53a8c1f2d19c8a994fd377388cf Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:39:34 +0200 Subject: [PATCH 49/96] Regenerate Java managed upload header fixtures --- .../TestGeneratedTusManagedUploadRuntime.java | 288 ++++++++++-------- 1 file changed, 163 insertions(+), 125 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 6097130c..b7d241bc 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -103,48 +103,56 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 201, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC50eHQ=" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC50eHQ=" - ), - }, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Location", - "https://tus.io/uploads/managed-durable-retry" - ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Location", + "https://tus.io/uploads/managed-durable-retry" + ), + } + ) ), new GeneratedTusManagedUploadRequest( "PATCH", "upload", 7, 204, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "0" - ), - new GeneratedTusManagedUploadHeader( - "Content-Type", - "application/offset+octet-stream" - ), - }, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + } ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ) ), } ), @@ -158,43 +166,51 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "upload", 0, 200, - true, - new GeneratedTusManagedUploadHeader[0], - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[0] ), - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" - ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ) ), new GeneratedTusManagedUploadRequest( "PATCH", "upload", 7, 204, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" - ), - new GeneratedTusManagedUploadHeader( - "Content-Type", - "application/offset+octet-stream" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + } ), - }, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "14" - ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "14" + ), + } + ) ), } ), @@ -262,19 +278,23 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 400, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" + ), + } ), - }, - false, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -349,19 +369,23 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 500, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" - ), - }, - false, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -379,19 +403,23 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 500, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } ), - }, - false, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -409,19 +437,23 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "endpoint", 0, 500, - true, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" - ), - }, - false, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -915,12 +947,12 @@ private HttpRequest requestFor( HttpRequest httpRequest = new HttpRequest() .withMethod(method) .withPath(pathFor(testCase, request)); - if (request.includesDefaultProtocolRequestHeaders) { + if (request.requestHeaders.includesDefaultProtocolHeaders) { for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { httpRequest.withHeader(entry.getKey(), entry.getValue()); } } - for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { + for (GeneratedTusManagedUploadHeader header : request.requestHeaders.headers) { httpRequest.withHeader(header.name, header.value); } if (methodOverride != null) { @@ -953,12 +985,12 @@ private HttpResponse responseFor( GeneratedTusManagedUploadRuntimeCase testCase, GeneratedTusManagedUploadRequest request) throws Exception { HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); - if (request.includesDefaultProtocolResponseHeaders) { + if (request.responseHeaders.includesDefaultProtocolHeaders) { for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { response.withHeader(entry.getKey(), entry.getValue()); } } - for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { + for (GeneratedTusManagedUploadHeader header : request.responseHeaders.headers) { response.withHeader(header.name, headerValueFor(testCase, header)); } return response; @@ -1185,31 +1217,37 @@ private static final class GeneratedTusManagedUploadRequest { final String url; final int bodySize; final int statusCode; - final boolean includesDefaultProtocolRequestHeaders; - final GeneratedTusManagedUploadHeader[] requestHeaders; - final boolean includesDefaultProtocolResponseHeaders; - final GeneratedTusManagedUploadHeader[] responseHeaders; + final GeneratedTusManagedUploadHeaderSet requestHeaders; + final GeneratedTusManagedUploadHeaderSet responseHeaders; GeneratedTusManagedUploadRequest( String method, String url, int bodySize, int statusCode, - boolean includesDefaultProtocolRequestHeaders, - GeneratedTusManagedUploadHeader[] requestHeaders, - boolean includesDefaultProtocolResponseHeaders, - GeneratedTusManagedUploadHeader[] responseHeaders) { + GeneratedTusManagedUploadHeaderSet requestHeaders, + GeneratedTusManagedUploadHeaderSet responseHeaders) { this.method = method; this.url = url; this.bodySize = bodySize; this.statusCode = statusCode; - this.includesDefaultProtocolRequestHeaders = includesDefaultProtocolRequestHeaders; this.requestHeaders = requestHeaders; - this.includesDefaultProtocolResponseHeaders = includesDefaultProtocolResponseHeaders; this.responseHeaders = responseHeaders; } } + private static final class GeneratedTusManagedUploadHeaderSet { + final boolean includesDefaultProtocolHeaders; + final GeneratedTusManagedUploadHeader[] headers; + + GeneratedTusManagedUploadHeaderSet( + boolean includesDefaultProtocolHeaders, + GeneratedTusManagedUploadHeader[] headers) { + this.includesDefaultProtocolHeaders = includesDefaultProtocolHeaders; + this.headers = headers; + } + } + private static final class GeneratedTusManagedUploadHeader { final String name; final String value; From 91b54af379b30be4bba8e3270856bf68b978c465 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:44:43 +0200 Subject: [PATCH 50/96] Expose generated Java protocol header defaults --- .../client/GeneratedTusProtocolContract.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index cba28960..f06d7061 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -6,10 +6,17 @@ package io.tus.java.client; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + /** * Generated TUS protocol contract fixture used by tests. */ final class GeneratedTusProtocolContract { + static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); + static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final GeneratedTusWireVersion[] WIRE_VERSIONS = new GeneratedTusWireVersion[] { new GeneratedTusWireVersion( true, @@ -1310,6 +1317,18 @@ final class GeneratedTusProtocolContract { private GeneratedTusProtocolContract() { } + private static Map defaultRequestHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + private static Map defaultResponseHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + /** * Generated wire-version fixture. */ From 15d46733c16b6604f1f5a67460eaac50f88bc320 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 02:09:40 +0200 Subject: [PATCH 51/96] Add generated TUS request ID proof --- ...eneratedTusClientConformanceScenarios.java | 25 +++++++++++ .../client/GeneratedTusProtocolContract.java | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 4eadec8a..eb7b22f2 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -482,6 +482,31 @@ final class GeneratedTusClientConformanceScenarios { new String[0] ) ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "request-id-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "requestIdHeaders", + "requestIdHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "add-request-id-header", + "apply-custom-request-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "resume-from-previous-upload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index f06d7061..8b73e6d8 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -599,6 +599,47 @@ final class GeneratedTusProtocolContract { "apply-custom-request-headers", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "requestIdHeaders", + }, + "covered-by-generated-scenario" + ), + "Add generated request IDs after protocol and custom request headers.", + "requestIdHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "add-request-id-header", + "", + "Generate a request ID and apply it after custom request headers so it is authoritative." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create uploads with a generated request ID." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with a generated request ID." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "add-request-id-header", + "apply-custom-request-headers", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { From 4fb2c6b851054269f90bfa2ffb85d916090b534b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:34:44 +0200 Subject: [PATCH 52/96] Cover chunked deferred TUS uploads --- .../java/io/tus/java/client/TusUploader.java | 12 ++ ...eneratedTusClientConformanceScenarios.java | 76 +++++++++ .../client/GeneratedTusProtocolContract.java | 8 +- .../TestGeneratedTusConformanceEvents.java | 46 ++++++ .../TestGeneratedTusManagedUploadRuntime.java | 16 +- .../client/TestGeneratedTusRuntimeEvents.java | 156 ++++++++++++++++-- 6 files changed, 290 insertions(+), 24 deletions(-) diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index d3b97d78..0e074b46 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -240,6 +240,10 @@ public void setChunkCompleteListener(ChunkCompleteListener listener) { * to the HTTP request. */ public int uploadChunk() throws IOException, ProtocolException { + if (isUploadComplete()) { + return -1; + } + openConnection(); notifyProgressAtRequestStart(); @@ -294,6 +298,10 @@ public int uploadChunk() throws IOException, ProtocolException { * to the HTTP request. */ @Deprecated public int uploadChunk(int chunkSize) throws IOException, ProtocolException { + if (isUploadComplete()) { + return -1; + } + openConnection(); byte[] buf = new byte[chunkSize]; @@ -440,6 +448,10 @@ private void notifyProgress(long bytesSent) { } } + private boolean isUploadComplete() { + return upload.getSize() > 0 && offset >= upload.getSize(); + } + private void notifyChunkComplete(long chunkSize, long bytesAccepted) { if (chunkCompleteListener != null) { chunkCompleteListener.onChunkComplete(chunkSize, bytesAccepted, upload.getSize()); diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index eb7b22f2..9a21b82c 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -147,6 +147,43 @@ final class GeneratedTusClientConformanceScenarios { } ) ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) + ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( @@ -747,6 +784,45 @@ final class GeneratedTusClientConformanceScenarios { } ) ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "deferred-length-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "deferredLengthUpload", + "deferredLengthChunkedUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-chunk-complete", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) + ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "override-patch-method", new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 8b73e6d8..6f6bbb24 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -454,10 +454,11 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "deferredLengthUpload", + "deferredLengthChunkedUpload", }, "covered-by-generated-scenario" ), - "Create an upload without a known length and declare the length on final PATCH.", + "Create an upload without a known length and declare the length on first PATCH.", "deferredLengthUpload", new GeneratedTusClientFeatureFlowStep[] { new GeneratedTusClientFeatureFlowStep( @@ -472,14 +473,14 @@ final class GeneratedTusProtocolContract { "", "defer-upload-length", "", - "Track the source until the final chunk reveals the total size." + "Track the source so the first PATCH can declare the total size." ), new GeneratedTusClientFeatureFlowStep( "operation", "patchTusUpload", "", "", - "Declare Upload-Length on the final chunk request." + "Declare Upload-Length on the first chunk request." ), }, new String[] { @@ -1062,6 +1063,7 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "ietfDraft05CreationWithUpload", + "ietfDraft05ChunkedUploadComplete", "ietfDraft03ResumeWithoutKnownLength", }, "covered-by-generated-scenario" diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 65b15407..9fe62867 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -91,6 +91,29 @@ public class TestGeneratedTusConformanceEvents { "source-close", } ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), new GeneratedTusEventCanaryCase( "protocolVersionSelection", "ietfDraft03ResumeWithoutKnownLength", @@ -233,6 +256,29 @@ public class TestGeneratedTusConformanceEvents { "source-close", } ), + new GeneratedTusEventCanaryCase( + "deferredLengthUpload", + "deferredLengthChunkedUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), new GeneratedTusEventCanaryCase( "parallelUploadConcat", "parallelUploadConcat", diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index b7d241bc..3bbb9ccd 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -134,14 +134,14 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadHeaderSet( true, new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "0" - ), new GeneratedTusManagedUploadHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), } ), new GeneratedTusManagedUploadHeaderSet( @@ -192,14 +192,14 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadHeaderSet( true, new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" - ), new GeneratedTusManagedUploadHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), } ), new GeneratedTusManagedUploadHeaderSet( diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index c1a93158..3c11df0a 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -77,14 +77,14 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { 204, true, new GeneratedTusRuntimeEventHeader[] { - new GeneratedTusRuntimeEventHeader( - "Upload-Offset", - "0" - ), new GeneratedTusRuntimeEventHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -149,14 +149,14 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { 204, true, new GeneratedTusRuntimeEventHeader[] { - new GeneratedTusRuntimeEventHeader( - "Upload-Offset", - "5" - ), new GeneratedTusRuntimeEventHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -222,14 +222,14 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { 204, true, new GeneratedTusRuntimeEventHeader[] { - new GeneratedTusRuntimeEventHeader( - "Upload-Offset", - "0" - ), new GeneratedTusRuntimeEventHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -295,6 +295,87 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { 204, true, new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + new GeneratedTusRuntimeEventCase( + "deferredLengthChunkedUpload", + "exact-except-extra-progress", + true, + new GeneratedTusRuntimeBeforeStartAction[0], + new GeneratedTusRuntimeEventInput( + "hello world", + "deferred-chunked-contract", + "absolute", + false, + 5, + null, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Defer-Length", + "1" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/deferred-chunked-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), new GeneratedTusRuntimeEventHeader( "Upload-Length", "11" @@ -303,10 +384,52 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "10" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { new GeneratedTusRuntimeEventHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "10" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -319,8 +442,14 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { }, new String[] { "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", "progress:11:11", - "chunk-complete:11:11:11", + "chunk-complete:1:11:11", } ), }; @@ -363,6 +492,7 @@ public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Except TusUploader uploader = uploaderFor(client, testCase); uploader.setChunkSize(testCase.input.chunkSize); + uploader.setRequestPayloadSize(testCase.input.chunkSize); uploader.setProgressListener(new TusUploader.ProgressListener() { @Override public void onProgress(long bytesSent, long bytesTotal) { From 04e6625542513a5f9340e5368c7ff0ae9e5a2dbd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:57:59 +0200 Subject: [PATCH 53/96] Align deferred length upload behavior --- .../java/io/tus/java/client/TusUpload.java | 2 +- .../java/io/tus/java/client/TusUploader.java | 10 +++- ...eneratedTusClientConformanceScenarios.java | 50 +++++++++++++++-- .../client/GeneratedTusProtocolContract.java | 10 +++- .../TestGeneratedTusConformanceEvents.java | 35 ++++++++++-- .../client/TestGeneratedTusRuntimeEvents.java | 55 +++++++++++++++---- 6 files changed, 134 insertions(+), 28 deletions(-) diff --git a/src/main/java/io/tus/java/client/TusUpload.java b/src/main/java/io/tus/java/client/TusUpload.java index ecd63732..7191fcc1 100644 --- a/src/main/java/io/tus/java/client/TusUpload.java +++ b/src/main/java/io/tus/java/client/TusUpload.java @@ -75,7 +75,7 @@ public boolean isUploadLengthDeferred() { * Set whether upload creation should defer declaring the upload length. * * When enabled, the upload is created with Upload-Defer-Length and the uploader declares - * Upload-Length on the first PATCH request. + * Upload-Length on the final upload request. * * @param uploadLengthDeferred True to use deferred upload length creation. */ diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index 0e074b46..a9f4b232 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -109,7 +109,7 @@ private void openConnection() throws IOException, ProtocolException { } client.prepareConnection(connection); connection.setRequestProperty("Upload-Offset", Long.toString(offset)); - if (!uploadLengthDeclared) { + if (shouldDeclareUploadLength()) { connection.setRequestProperty("Upload-Length", Long.toString(upload.getSize())); requestDeclaresUploadLength = true; } @@ -207,6 +207,14 @@ public int getRequestPayloadSize() { return requestPayloadSize; } + private boolean shouldDeclareUploadLength() { + if (uploadLengthDeclared) { + return false; + } + + return offset + requestPayloadSize >= upload.getSize(); + } + /** * Set the listener used for upload progress events. * diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 9a21b82c..3b93fe3f 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -35,6 +35,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -68,6 +69,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -99,6 +101,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -135,6 +138,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -165,6 +169,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -202,6 +207,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -231,6 +237,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -252,6 +259,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -273,6 +281,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -294,6 +303,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -315,6 +325,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -336,6 +347,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -357,6 +369,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -378,6 +391,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -399,6 +413,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -420,6 +435,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -443,6 +459,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -466,6 +483,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -490,6 +508,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -514,6 +533,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -539,6 +559,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -564,6 +585,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -599,6 +621,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -631,6 +654,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -659,6 +683,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -687,6 +712,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -715,6 +741,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -743,6 +770,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -771,6 +799,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" ), @@ -804,17 +833,18 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" ), new String[] { "upload-url-available", - "progress:0:11", - "progress:5:11", - "chunk-complete:5:5:11", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", "progress:10:11", "progress:11:11", "chunk-complete:1:11:11", @@ -842,6 +872,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -869,6 +900,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -905,6 +937,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -936,6 +969,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -964,6 +998,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -992,6 +1027,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -1020,6 +1056,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -1049,6 +1086,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 6f6bbb24..7fed6f99 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -458,7 +458,7 @@ final class GeneratedTusProtocolContract { }, "covered-by-generated-scenario" ), - "Create an upload without a known length and declare the length on first PATCH.", + "Create an upload without a known length and declare the length on the final upload request.", "deferredLengthUpload", new GeneratedTusClientFeatureFlowStep[] { new GeneratedTusClientFeatureFlowStep( @@ -473,14 +473,14 @@ final class GeneratedTusProtocolContract { "", "defer-upload-length", "", - "Track the source so the first PATCH can declare the total size." + "Track the source until the final upload request reveals the total size." ), new GeneratedTusClientFeatureFlowStep( "operation", "patchTusUpload", "", "", - "Declare Upload-Length on the first chunk request." + "Declare Upload-Length on the final upload request." ), }, new String[] { @@ -489,6 +489,7 @@ final class GeneratedTusProtocolContract { }, new String[] { "defer-upload-length", + "emit-chunk-complete", "emit-progress", } ), @@ -1622,14 +1623,17 @@ static final class GeneratedTusClientConformanceEvents { */ static final class GeneratedTusClientConformanceEventPolicy { final String matching; + final String deferredLengthBytesTotal; final String progress; final String transportProgress; GeneratedTusClientConformanceEventPolicy( String matching, + String deferredLengthBytesTotal, String progress, String transportProgress) { this.matching = matching; + this.deferredLengthBytesTotal = deferredLengthBytesTotal; this.progress = progress; this.transportProgress = transportProgress; } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 9fe62867..5d9c6da2 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -22,6 +22,7 @@ public class TestGeneratedTusConformanceEvents { "singleUploadLifecycle", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -41,6 +42,7 @@ public class TestGeneratedTusConformanceEvents { "creationWithUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -57,6 +59,7 @@ public class TestGeneratedTusConformanceEvents { "creationWithUploadPartialChunk", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -80,6 +83,7 @@ public class TestGeneratedTusConformanceEvents { "ietfDraft05CreationWithUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -96,6 +100,7 @@ public class TestGeneratedTusConformanceEvents { "ietfDraft05ChunkedUploadComplete", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -119,6 +124,7 @@ public class TestGeneratedTusConformanceEvents { "ietfDraft03ResumeWithoutKnownLength", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -136,6 +142,7 @@ public class TestGeneratedTusConformanceEvents { "resumeFromPreviousUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -157,6 +164,7 @@ public class TestGeneratedTusConformanceEvents { "relativeLocationResolution", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -175,6 +183,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -189,6 +198,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -203,6 +213,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -217,6 +228,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -231,6 +243,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -244,6 +257,7 @@ public class TestGeneratedTusConformanceEvents { "deferredLengthUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" ), @@ -261,17 +275,18 @@ public class TestGeneratedTusConformanceEvents { "deferredLengthChunkedUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" ), new String[] { "upload-url-available", - "progress:0:11", - "progress:5:11", - "chunk-complete:5:5:11", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", "progress:10:11", "progress:11:11", "chunk-complete:1:11:11", @@ -284,6 +299,7 @@ public class TestGeneratedTusConformanceEvents { "parallelUploadConcat", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -300,6 +316,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -312,6 +329,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -327,6 +345,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -342,6 +361,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -354,6 +374,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -366,6 +387,7 @@ public class TestGeneratedTusConformanceEvents { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -557,6 +579,7 @@ private static void assertEventPolicyEquals( GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy expected, GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy actual) { assertEquals(expected.matching, actual.matching); + assertEquals(expected.deferredLengthBytesTotal, actual.deferredLengthBytesTotal); assertEquals(expected.progress, actual.progress); assertEquals(expected.transportProgress, actual.transportProgress); } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 3c11df0a..7b298dc3 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -31,6 +31,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase( "singleUploadLifecycle", "exact-except-extra-progress", + null, false, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -104,6 +105,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase( "resumeFromPreviousUpload", "exact-except-extra-progress", + null, false, new GeneratedTusRuntimeBeforeStartAction[] { new GeneratedTusRuntimeBeforeStartAction( @@ -176,6 +178,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase( "relativeLocationResolution", "exact-except-extra-progress", + null, false, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -249,6 +252,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase( "deferredLengthUpload", "exact-except-extra-progress", + "allow-known-total-before-declaration", true, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -326,6 +330,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase( "deferredLengthChunkedUpload", "exact-except-extra-progress", + "allow-known-total-before-declaration", true, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -376,10 +381,6 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), - new GeneratedTusRuntimeEventHeader( - "Upload-Length", - "11" - ), new GeneratedTusRuntimeEventHeader( "Upload-Offset", "0" @@ -426,6 +427,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), new GeneratedTusRuntimeEventHeader( "Upload-Offset", "10" @@ -441,12 +446,12 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), }, new String[] { - "progress:0:11", - "progress:5:11", - "chunk-complete:5:5:11", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", "progress:10:11", "progress:11:11", "chunk-complete:1:11:11", @@ -743,7 +748,7 @@ private void assertEventsExactExceptExtraProgress( for (String event : events) { if ( expectedIndex < testCase.eventKeys.length - && event.equals(testCase.eventKeys[expectedIndex])) { + && eventMatchesExpected(testCase, event, testCase.eventKeys[expectedIndex])) { expectedIndex += 1; continue; } @@ -772,9 +777,35 @@ private void assertEventsExactExceptExtraProgress( + java.util.Arrays.toString(testCase.eventKeys)); } + private boolean eventMatchesExpected( + GeneratedTusRuntimeEventCase testCase, + String event, + String expected) { + if (event.equals(expected)) { + return true; + } + + if ( + !"allow-known-total-before-declaration" + .equals(testCase.eventPolicyDeferredLengthBytesTotal)) { + return false; + } + + if (!testCase.uploadLengthDeferred || !expected.endsWith(":null")) { + return false; + } + + String expectedPrefix = expected.substring(0, expected.length() - ":null".length()); + String localKnownTotal = + ":" + testCase.input.content.getBytes(StandardCharsets.UTF_8).length; + + return event.equals(expectedPrefix + localKnownTotal); + } + private static final class GeneratedTusRuntimeEventCase { final String scenarioId; final String eventPolicyMatching; + final String eventPolicyDeferredLengthBytesTotal; final boolean uploadLengthDeferred; final GeneratedTusRuntimeBeforeStartAction[] beforeStartActions; final GeneratedTusRuntimeEventInput input; @@ -784,6 +815,7 @@ private static final class GeneratedTusRuntimeEventCase { GeneratedTusRuntimeEventCase( String scenarioId, String eventPolicyMatching, + String eventPolicyDeferredLengthBytesTotal, boolean uploadLengthDeferred, GeneratedTusRuntimeBeforeStartAction[] beforeStartActions, GeneratedTusRuntimeEventInput input, @@ -791,6 +823,7 @@ private static final class GeneratedTusRuntimeEventCase { String[] eventKeys) { this.scenarioId = scenarioId; this.eventPolicyMatching = eventPolicyMatching; + this.eventPolicyDeferredLengthBytesTotal = eventPolicyDeferredLengthBytesTotal; this.uploadLengthDeferred = uploadLengthDeferred; this.beforeStartActions = beforeStartActions; this.input = input; From 67cd4975980a16bbc47c217fef51d053eaefff34 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:02:47 +0200 Subject: [PATCH 54/96] Group generated runtime event policy --- .../client/TestGeneratedTusRuntimeEvents.java | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 7b298dc3..4c09a18f 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -30,8 +30,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase[] { new GeneratedTusRuntimeEventCase( "singleUploadLifecycle", - "exact-except-extra-progress", - null, + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress", + null + ), false, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -104,8 +106,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "resumeFromPreviousUpload", - "exact-except-extra-progress", - null, + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress", + null + ), false, new GeneratedTusRuntimeBeforeStartAction[] { new GeneratedTusRuntimeBeforeStartAction( @@ -177,8 +181,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "relativeLocationResolution", - "exact-except-extra-progress", - null, + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress", + null + ), false, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -251,8 +257,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "deferredLengthUpload", - "exact-except-extra-progress", - "allow-known-total-before-declaration", + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress", + "allow-known-total-before-declaration" + ), true, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -329,8 +337,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), new GeneratedTusRuntimeEventCase( "deferredLengthChunkedUpload", - "exact-except-extra-progress", - "allow-known-total-before-declaration", + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress", + "allow-known-total-before-declaration" + ), true, new GeneratedTusRuntimeBeforeStartAction[0], new GeneratedTusRuntimeEventInput( @@ -721,7 +731,7 @@ private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { } private void assertEvents(GeneratedTusRuntimeEventCase testCase, List events) { - if ("exact".equals(testCase.eventPolicyMatching)) { + if ("exact".equals(testCase.eventPolicy.matching)) { assertArrayEquals( testCase.scenarioId, testCase.eventKeys, @@ -729,14 +739,14 @@ private void assertEvents(GeneratedTusRuntimeEventCase testCase, List ev return; } - if ("exact-except-extra-progress".equals(testCase.eventPolicyMatching)) { + if ("exact-except-extra-progress".equals(testCase.eventPolicy.matching)) { assertEventsExactExceptExtraProgress(testCase, events); return; } throw new AssertionError( "Unsupported generated event policy " - + testCase.eventPolicyMatching + + testCase.eventPolicy.matching + " for " + testCase.scenarioId); } @@ -787,7 +797,7 @@ private boolean eventMatchesExpected( if ( !"allow-known-total-before-declaration" - .equals(testCase.eventPolicyDeferredLengthBytesTotal)) { + .equals(testCase.eventPolicy.deferredLengthBytesTotal)) { return false; } @@ -804,8 +814,7 @@ private boolean eventMatchesExpected( private static final class GeneratedTusRuntimeEventCase { final String scenarioId; - final String eventPolicyMatching; - final String eventPolicyDeferredLengthBytesTotal; + final GeneratedTusRuntimeEventPolicy eventPolicy; final boolean uploadLengthDeferred; final GeneratedTusRuntimeBeforeStartAction[] beforeStartActions; final GeneratedTusRuntimeEventInput input; @@ -814,16 +823,14 @@ private static final class GeneratedTusRuntimeEventCase { GeneratedTusRuntimeEventCase( String scenarioId, - String eventPolicyMatching, - String eventPolicyDeferredLengthBytesTotal, + GeneratedTusRuntimeEventPolicy eventPolicy, boolean uploadLengthDeferred, GeneratedTusRuntimeBeforeStartAction[] beforeStartActions, GeneratedTusRuntimeEventInput input, GeneratedTusRuntimeEventRequest[] requests, String[] eventKeys) { this.scenarioId = scenarioId; - this.eventPolicyMatching = eventPolicyMatching; - this.eventPolicyDeferredLengthBytesTotal = eventPolicyDeferredLengthBytesTotal; + this.eventPolicy = eventPolicy; this.uploadLengthDeferred = uploadLengthDeferred; this.beforeStartActions = beforeStartActions; this.input = input; @@ -832,6 +839,18 @@ private static final class GeneratedTusRuntimeEventCase { } } + private static final class GeneratedTusRuntimeEventPolicy { + final String matching; + final String deferredLengthBytesTotal; + + GeneratedTusRuntimeEventPolicy( + String matching, + String deferredLengthBytesTotal) { + this.matching = matching; + this.deferredLengthBytesTotal = deferredLengthBytesTotal; + } + } + private static final class GeneratedTusRuntimeBeforeStartAction { final String kind; final int expectedPreviousUploadCount; From e592678aadb4d1b251be078b9bb5c8003cecd81d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:21:12 +0200 Subject: [PATCH 55/96] Regenerate TUS event alternatives --- ...eneratedTusClientConformanceScenarios.java | 218 +++++++++++++++-- .../client/GeneratedTusProtocolContract.java | 7 +- .../TestGeneratedTusConformanceEvents.java | 185 ++++++++++++++- .../client/TestGeneratedTusRuntimeEvents.java | 221 +++++++++++------- 4 files changed, 527 insertions(+), 104 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 3b93fe3f..1eef4eef 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -48,6 +48,16 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -79,6 +89,13 @@ final class GeneratedTusClientConformanceScenarios { "upload-url-available", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -118,6 +135,20 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -148,6 +179,13 @@ final class GeneratedTusClientConformanceScenarios { "upload-url-available", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -186,6 +224,20 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -218,6 +270,14 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:6:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -240,7 +300,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -262,7 +323,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -284,7 +346,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -306,7 +369,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -328,7 +392,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -350,7 +415,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -372,7 +438,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -394,7 +461,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -416,7 +484,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -438,7 +507,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -462,7 +532,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -486,7 +557,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -511,7 +583,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -536,7 +609,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -562,7 +636,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -600,6 +675,18 @@ final class GeneratedTusClientConformanceScenarios { "url-storage-remove:tus::contract-resume-fingerprint::1337", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -632,6 +719,14 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -661,6 +756,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:array-buffer:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -690,6 +790,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:array-buffer-view:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -719,6 +824,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:web-readable-stream:null", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -748,6 +858,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:node-readable-stream:null", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -777,6 +892,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:node-path-reference:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -810,6 +930,14 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -850,6 +978,32 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -875,7 +1029,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -909,6 +1064,12 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:5:5:11", "progress:11:11", "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -942,6 +1103,9 @@ final class GeneratedTusClientConformanceScenarios { ), new String[] { "request-abort:3", + }, + new String[][] { + new String[0], } ) ), @@ -977,6 +1141,12 @@ final class GeneratedTusClientConformanceScenarios { "retry-schedule:0", "should-retry:0:true", "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -1006,6 +1176,12 @@ final class GeneratedTusClientConformanceScenarios { "after-response:0", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -1032,6 +1208,9 @@ final class GeneratedTusClientConformanceScenarios { ), new String[] { "request-abort:0", + }, + new String[][] { + new String[0], } ) ), @@ -1061,6 +1240,9 @@ final class GeneratedTusClientConformanceScenarios { ), new String[] { "request-abort:1", + }, + new String[][] { + new String[0], } ) ), @@ -1092,6 +1274,10 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "should-retry:0:true", "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], } ) ), diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 7fed6f99..4e24965c 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1582,6 +1582,7 @@ static final class GeneratedTusClientConformanceScenario { final String[] primitives; final GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; + final String[][] eventKeyAlternativeGroups; GeneratedTusClientConformanceScenario( String behavior, @@ -1600,6 +1601,7 @@ static final class GeneratedTusClientConformanceScenario { this.primitives = primitives; this.eventPolicy = events.policy; this.eventKeys = events.keys; + this.eventKeyAlternativeGroups = events.alternativeGroups; } } @@ -1609,12 +1611,15 @@ static final class GeneratedTusClientConformanceScenario { static final class GeneratedTusClientConformanceEvents { final GeneratedTusClientConformanceEventPolicy policy; final String[] keys; + final String[][] alternativeGroups; GeneratedTusClientConformanceEvents( GeneratedTusClientConformanceEventPolicy policy, - String[] keys) { + String[] keys, + String[][] alternativeGroups) { this.policy = policy; this.keys = keys; + this.alternativeGroups = alternativeGroups; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 5d9c6da2..a5ac0a04 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -35,6 +35,16 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -52,6 +62,13 @@ public class TestGeneratedTusConformanceEvents { "upload-url-available", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -76,6 +93,20 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -93,6 +124,13 @@ public class TestGeneratedTusConformanceEvents { "upload-url-available", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -117,6 +155,20 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -135,6 +187,14 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:6:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -157,6 +217,18 @@ public class TestGeneratedTusConformanceEvents { "url-storage-remove:tus::contract-resume-fingerprint::1337", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -175,6 +247,14 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -190,6 +270,11 @@ public class TestGeneratedTusConformanceEvents { "source-open:array-buffer:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -205,6 +290,11 @@ public class TestGeneratedTusConformanceEvents { "source-open:array-buffer-view:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -220,6 +310,11 @@ public class TestGeneratedTusConformanceEvents { "source-open:web-readable-stream:null", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -235,6 +330,11 @@ public class TestGeneratedTusConformanceEvents { "source-open:node-readable-stream:null", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -250,6 +350,11 @@ public class TestGeneratedTusConformanceEvents { "source-open:node-path-reference:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -268,6 +373,14 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -292,6 +405,32 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -308,6 +447,12 @@ public class TestGeneratedTusConformanceEvents { "chunk-complete:5:5:11", "progress:11:11", "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -321,6 +466,9 @@ public class TestGeneratedTusConformanceEvents { ), new String[] { "request-abort:3", + }, + new String[][] { + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -337,6 +485,12 @@ public class TestGeneratedTusConformanceEvents { "retry-schedule:0", "should-retry:0:true", "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -353,6 +507,12 @@ public class TestGeneratedTusConformanceEvents { "after-response:0", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -366,6 +526,9 @@ public class TestGeneratedTusConformanceEvents { ), new String[] { "request-abort:0", + }, + new String[][] { + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -379,6 +542,9 @@ public class TestGeneratedTusConformanceEvents { ), new String[] { "request-abort:1", + }, + new String[][] { + new String[0], } ), new GeneratedTusEventCanaryCase( @@ -393,6 +559,10 @@ public class TestGeneratedTusConformanceEvents { new String[] { "should-retry:0:true", "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], } ), }; @@ -493,6 +663,9 @@ public void testGeneratedScenarioEventKeys() { assertContains(feature.conformance.scenarioIds, scenario.scenarioId); assertEventPolicyEquals(testCase.eventPolicy, scenario.eventPolicy); assertArrayEquals(testCase.eventKeys, scenario.eventKeys); + assertStringMatrixEquals( + testCase.eventKeyAlternativeGroups, + scenario.eventKeyAlternativeGroups); } } @@ -584,21 +757,31 @@ private static void assertEventPolicyEquals( assertEquals(expected.transportProgress, actual.transportProgress); } + private static void assertStringMatrixEquals(String[][] expected, String[][] actual) { + assertEquals(expected.length, actual.length); + for (int index = 0; index < expected.length; index += 1) { + assertArrayEquals(expected[index], actual[index]); + } + } + private static final class GeneratedTusEventCanaryCase { final String featureId; final String scenarioId; final GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; + final String[][] eventKeyAlternativeGroups; GeneratedTusEventCanaryCase( String featureId, String scenarioId, GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy, - String[] eventKeys) { + String[] eventKeys, + String[][] eventKeyAlternativeGroups) { this.featureId = featureId; this.scenarioId = scenarioId; this.eventPolicy = eventPolicy; this.eventKeys = eventKeys; + this.eventKeyAlternativeGroups = eventKeyAlternativeGroups; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 4c09a18f..43b45202 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -30,9 +30,20 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new GeneratedTusRuntimeEventCase[] { new GeneratedTusRuntimeEventCase( "singleUploadLifecycle", - new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress", - null + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + } ), false, new GeneratedTusRuntimeBeforeStartAction[0], @@ -97,18 +108,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), } ), - }, - new String[] { - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - } + } ), new GeneratedTusRuntimeEventCase( "resumeFromPreviousUpload", - new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress", - null + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress" + ), + new String[] { + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + } ), false, new GeneratedTusRuntimeBeforeStartAction[] { @@ -172,18 +189,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), } ), - }, - new String[] { - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - } + } ), new GeneratedTusRuntimeEventCase( "relativeLocationResolution", - new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress", - null + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + } ), false, new GeneratedTusRuntimeBeforeStartAction[0], @@ -248,18 +271,24 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), } ), - }, - new String[] { - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - } + } ), new GeneratedTusRuntimeEventCase( "deferredLengthUpload", - new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress", - "allow-known-total-before-declaration" + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + } ), true, new GeneratedTusRuntimeBeforeStartAction[0], @@ -328,18 +357,48 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), } ), - }, - new String[] { - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - } + } ), new GeneratedTusRuntimeEventCase( "deferredLengthChunkedUpload", - new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress", - "allow-known-total-before-declaration" + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-extra-progress" + ), + new String[] { + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + }, + new String[][] { + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + } ), true, new GeneratedTusRuntimeBeforeStartAction[0], @@ -454,18 +513,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { ), } ), - }, - new String[] { - "progress:0:null", - "progress:5:null", - "chunk-complete:5:5:null", - "progress:5:null", - "progress:10:null", - "chunk-complete:5:10:null", - "progress:10:11", - "progress:11:11", - "chunk-complete:1:11:11", - } + } ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = @@ -731,22 +779,22 @@ private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { } private void assertEvents(GeneratedTusRuntimeEventCase testCase, List events) { - if ("exact".equals(testCase.eventPolicy.matching)) { + if ("exact".equals(testCase.eventExpectations.policy.matching)) { assertArrayEquals( testCase.scenarioId, - testCase.eventKeys, + testCase.eventExpectations.keys, events.toArray(new String[events.size()])); return; } - if ("exact-except-extra-progress".equals(testCase.eventPolicy.matching)) { + if ("exact-except-extra-progress".equals(testCase.eventExpectations.policy.matching)) { assertEventsExactExceptExtraProgress(testCase, events); return; } throw new AssertionError( "Unsupported generated event policy " - + testCase.eventPolicy.matching + + testCase.eventExpectations.policy.matching + " for " + testCase.scenarioId); } @@ -757,8 +805,8 @@ private void assertEventsExactExceptExtraProgress( int expectedIndex = 0; for (String event : events) { if ( - expectedIndex < testCase.eventKeys.length - && eventMatchesExpected(testCase, event, testCase.eventKeys[expectedIndex])) { + expectedIndex < testCase.eventExpectations.keys.length + && eventMatchesExpected(testCase, event, expectedIndex)) { expectedIndex += 1; continue; } @@ -772,10 +820,10 @@ && eventMatchesExpected(testCase, event, testCase.eventKeys[expectedIndex])) { + " emitted unexpected non-progress event " + event + "; expected " - + java.util.Arrays.toString(testCase.eventKeys)); + + java.util.Arrays.toString(testCase.eventExpectations.keys)); } - if (expectedIndex == testCase.eventKeys.length) { + if (expectedIndex == testCase.eventExpectations.keys.length) { return; } @@ -784,70 +832,71 @@ && eventMatchesExpected(testCase, event, testCase.eventKeys[expectedIndex])) { + " did not emit every expected non-extra event; observed " + events + "; expected " - + java.util.Arrays.toString(testCase.eventKeys)); + + java.util.Arrays.toString(testCase.eventExpectations.keys)); } private boolean eventMatchesExpected( GeneratedTusRuntimeEventCase testCase, String event, - String expected) { + int expectedIndex) { + String expected = testCase.eventExpectations.keys[expectedIndex]; if (event.equals(expected)) { return true; } - if ( - !"allow-known-total-before-declaration" - .equals(testCase.eventPolicy.deferredLengthBytesTotal)) { - return false; - } - - if (!testCase.uploadLengthDeferred || !expected.endsWith(":null")) { - return false; + for (String alternative : testCase.eventExpectations.alternativeGroups[expectedIndex]) { + if (event.equals(alternative)) { + return true; + } } - String expectedPrefix = expected.substring(0, expected.length() - ":null".length()); - String localKnownTotal = - ":" + testCase.input.content.getBytes(StandardCharsets.UTF_8).length; - - return event.equals(expectedPrefix + localKnownTotal); + return false; } private static final class GeneratedTusRuntimeEventCase { final String scenarioId; - final GeneratedTusRuntimeEventPolicy eventPolicy; + final GeneratedTusRuntimeEventExpectations eventExpectations; final boolean uploadLengthDeferred; final GeneratedTusRuntimeBeforeStartAction[] beforeStartActions; final GeneratedTusRuntimeEventInput input; final GeneratedTusRuntimeEventRequest[] requests; - final String[] eventKeys; GeneratedTusRuntimeEventCase( String scenarioId, - GeneratedTusRuntimeEventPolicy eventPolicy, + GeneratedTusRuntimeEventExpectations eventExpectations, boolean uploadLengthDeferred, GeneratedTusRuntimeBeforeStartAction[] beforeStartActions, GeneratedTusRuntimeEventInput input, - GeneratedTusRuntimeEventRequest[] requests, - String[] eventKeys) { + GeneratedTusRuntimeEventRequest[] requests) { this.scenarioId = scenarioId; - this.eventPolicy = eventPolicy; + this.eventExpectations = eventExpectations; this.uploadLengthDeferred = uploadLengthDeferred; this.beforeStartActions = beforeStartActions; this.input = input; this.requests = requests; - this.eventKeys = eventKeys; + } + } + + private static final class GeneratedTusRuntimeEventExpectations { + final GeneratedTusRuntimeEventPolicy policy; + final String[] keys; + final String[][] alternativeGroups; + + GeneratedTusRuntimeEventExpectations( + GeneratedTusRuntimeEventPolicy policy, + String[] keys, + String[][] alternativeGroups) { + this.policy = policy; + this.keys = keys; + this.alternativeGroups = alternativeGroups; } } private static final class GeneratedTusRuntimeEventPolicy { final String matching; - final String deferredLengthBytesTotal; - GeneratedTusRuntimeEventPolicy( - String matching, - String deferredLengthBytesTotal) { + GeneratedTusRuntimeEventPolicy(String matching) { this.matching = matching; - this.deferredLengthBytesTotal = deferredLengthBytesTotal; } } From cc6750fc440411248845767ffdd01ad83fe13c69 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:37:17 +0200 Subject: [PATCH 56/96] Regenerate TUS extra event prefixes --- ...eneratedTusClientConformanceScenarios.java | 114 +++++++++++++----- .../client/GeneratedTusProtocolContract.java | 7 +- .../TestGeneratedTusConformanceEvents.java | 72 +++++++++-- .../client/TestGeneratedTusRuntimeEvents.java | 34 +++++- 4 files changed, 185 insertions(+), 42 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 1eef4eef..ff168996 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -58,6 +58,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -96,6 +99,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -149,6 +155,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -186,6 +195,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -238,6 +250,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -278,6 +293,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -301,7 +319,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -324,7 +343,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -347,7 +367,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -370,7 +391,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -393,7 +415,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -416,7 +439,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -439,7 +463,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -462,7 +487,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -485,7 +511,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -508,7 +535,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -533,7 +561,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -558,7 +587,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -584,7 +614,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -610,7 +641,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -637,7 +669,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -687,6 +720,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -727,6 +763,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -761,7 +800,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -795,7 +835,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -829,7 +870,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -863,7 +905,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -897,7 +940,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -938,6 +982,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -1004,6 +1051,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -1030,7 +1080,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1070,6 +1121,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -1106,7 +1160,8 @@ final class GeneratedTusClientConformanceScenarios { }, new String[][] { new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1147,7 +1202,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1182,7 +1238,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1211,7 +1268,8 @@ final class GeneratedTusClientConformanceScenarios { }, new String[][] { new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1243,7 +1301,8 @@ final class GeneratedTusClientConformanceScenarios { }, new String[][] { new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1278,7 +1337,8 @@ final class GeneratedTusClientConformanceScenarios { new String[][] { new String[0], new String[0], - } + }, + new String[0] ) ), }; diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 4e24965c..729c4464 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1583,6 +1583,7 @@ static final class GeneratedTusClientConformanceScenario { final GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; final String[][] eventKeyAlternativeGroups; + final String[] eventKeyExtraPrefixes; GeneratedTusClientConformanceScenario( String behavior, @@ -1602,6 +1603,7 @@ static final class GeneratedTusClientConformanceScenario { this.eventPolicy = events.policy; this.eventKeys = events.keys; this.eventKeyAlternativeGroups = events.alternativeGroups; + this.eventKeyExtraPrefixes = events.extraPrefixes; } } @@ -1612,14 +1614,17 @@ static final class GeneratedTusClientConformanceEvents { final GeneratedTusClientConformanceEventPolicy policy; final String[] keys; final String[][] alternativeGroups; + final String[] extraPrefixes; GeneratedTusClientConformanceEvents( GeneratedTusClientConformanceEventPolicy policy, String[] keys, - String[][] alternativeGroups) { + String[][] alternativeGroups, + String[] extraPrefixes) { this.policy = policy; this.keys = keys; this.alternativeGroups = alternativeGroups; + this.extraPrefixes = extraPrefixes; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index a5ac0a04..87ec23b6 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -45,6 +45,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -69,6 +72,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -107,6 +113,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -131,6 +140,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -169,6 +181,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -195,6 +210,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -229,6 +247,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -255,6 +276,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -275,7 +299,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "inputSources", @@ -295,7 +320,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "inputSources", @@ -315,7 +341,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "inputSources", @@ -335,7 +362,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "inputSources", @@ -355,7 +383,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "deferredLengthUpload", @@ -381,6 +410,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -431,6 +463,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -453,6 +488,9 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), new GeneratedTusEventCanaryCase( @@ -469,7 +507,8 @@ public class TestGeneratedTusConformanceEvents { }, new String[][] { new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "retryOffsetRecovery", @@ -491,7 +530,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "requestLifecycleHooks", @@ -513,7 +553,8 @@ public class TestGeneratedTusConformanceEvents { new String[0], new String[0], new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "abortUpload", @@ -529,7 +570,8 @@ public class TestGeneratedTusConformanceEvents { }, new String[][] { new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "abortUpload", @@ -545,7 +587,8 @@ public class TestGeneratedTusConformanceEvents { }, new String[][] { new String[0], - } + }, + new String[0] ), new GeneratedTusEventCanaryCase( "terminateUpload", @@ -563,7 +606,8 @@ public class TestGeneratedTusConformanceEvents { new String[][] { new String[0], new String[0], - } + }, + new String[0] ), }; @@ -666,6 +710,7 @@ public void testGeneratedScenarioEventKeys() { assertStringMatrixEquals( testCase.eventKeyAlternativeGroups, scenario.eventKeyAlternativeGroups); + assertArrayEquals(testCase.eventKeyExtraPrefixes, scenario.eventKeyExtraPrefixes); } } @@ -770,18 +815,21 @@ private static final class GeneratedTusEventCanaryCase { final GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; final String[][] eventKeyAlternativeGroups; + final String[] eventKeyExtraPrefixes; GeneratedTusEventCanaryCase( String featureId, String scenarioId, GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy, String[] eventKeys, - String[][] eventKeyAlternativeGroups) { + String[][] eventKeyAlternativeGroups, + String[] eventKeyExtraPrefixes) { this.featureId = featureId; this.scenarioId = scenarioId; this.eventPolicy = eventPolicy; this.eventKeys = eventKeys; this.eventKeyAlternativeGroups = eventKeyAlternativeGroups; + this.eventKeyExtraPrefixes = eventKeyExtraPrefixes; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 43b45202..7acd545f 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -43,6 +43,9 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), false, @@ -125,6 +128,9 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), false, @@ -206,6 +212,9 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), false, @@ -288,6 +297,9 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), true, @@ -398,6 +410,9 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ), true, @@ -811,7 +826,7 @@ && eventMatchesExpected(testCase, event, expectedIndex)) { continue; } - if (event.startsWith("progress:")) { + if (eventHasAllowedExtraPrefix(testCase, event)) { continue; } @@ -853,6 +868,18 @@ private boolean eventMatchesExpected( return false; } + private boolean eventHasAllowedExtraPrefix( + GeneratedTusRuntimeEventCase testCase, + String event) { + for (String prefix : testCase.eventExpectations.extraPrefixes) { + if (event.startsWith(prefix)) { + return true; + } + } + + return false; + } + private static final class GeneratedTusRuntimeEventCase { final String scenarioId; final GeneratedTusRuntimeEventExpectations eventExpectations; @@ -881,14 +908,17 @@ private static final class GeneratedTusRuntimeEventExpectations { final GeneratedTusRuntimeEventPolicy policy; final String[] keys; final String[][] alternativeGroups; + final String[] extraPrefixes; GeneratedTusRuntimeEventExpectations( GeneratedTusRuntimeEventPolicy policy, String[] keys, - String[][] alternativeGroups) { + String[][] alternativeGroups, + String[] extraPrefixes) { this.policy = policy; this.keys = keys; this.alternativeGroups = alternativeGroups; + this.extraPrefixes = extraPrefixes; } } From 7de71866f0879ab9ad2d0f1127aca8e262436412 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:12:05 +0200 Subject: [PATCH 57/96] Regenerate Java TUS event key helpers --- .../client/TestGeneratedTusRuntimeEvents.java | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 7acd545f..e6725a56 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -574,13 +574,18 @@ public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Except uploader.setProgressListener(new TusUploader.ProgressListener() { @Override public void onProgress(long bytesSent, long bytesTotal) { - events.add("progress:" + bytesSent + ":" + bytesTotal); + events.add(generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusEventKeyNumber(bytesTotal))); } }); uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { @Override public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { - events.add("chunk-complete:" + chunkSize + ":" + bytesAccepted + ":" + bytesTotal); + events.add(generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusEventKeyNumber(bytesTotal))); } }); @@ -594,6 +599,78 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) } } + private static String generatedTusEventKey(String kind, String... parts) { + if (parts.length == 0) { + return kind; + } + + return kind + ":" + String.join(":", parts); + } + + private static String generatedTusEventKeyNumber(long value) { + return Long.toString(value); + } + + private static String generatedTusEventKeyAfterResponse(String requestIndex) { + return generatedTusEventKey("after-response", requestIndex); + } + + private static String generatedTusEventKeyBeforeRequest(String requestIndex) { + return generatedTusEventKey("before-request", requestIndex); + } + + private static String generatedTusEventKeyChunkComplete(String chunkSize, String bytesAccepted, String bytesTotal) { + return generatedTusEventKey("chunk-complete", chunkSize, bytesAccepted, bytesTotal); + } + + private static String generatedTusEventKeyFingerprint(String fingerprint) { + return generatedTusEventKey("fingerprint", fingerprint); + } + + private static String generatedTusEventKeyProgress(String bytesSent, String bytesTotal) { + return generatedTusEventKey("progress", bytesSent, bytesTotal); + } + + private static String generatedTusEventKeyRequestAbort(String requestIndex) { + return generatedTusEventKey("request-abort", requestIndex); + } + + private static String generatedTusEventKeyRetrySchedule(String delay) { + return generatedTusEventKey("retry-schedule", delay); + } + + private static String generatedTusEventKeyShouldRetry(String retryAttempt, String decision) { + return generatedTusEventKey("should-retry", retryAttempt, decision); + } + + private static String generatedTusEventKeySourceClose() { + return generatedTusEventKey("source-close"); + } + + private static String generatedTusEventKeySourceOpen(String inputKind, String size) { + return generatedTusEventKey("source-open", inputKind, size); + } + + private static String generatedTusEventKeySuccess() { + return generatedTusEventKey("success"); + } + + private static String generatedTusEventKeyUploadUrlAvailable() { + return generatedTusEventKey("upload-url-available"); + } + + private static String generatedTusEventKeyUrlStorageAdd(String fingerprint, String uploadUrl) { + return generatedTusEventKey("url-storage-add", fingerprint, uploadUrl); + } + + private static String generatedTusEventKeyUrlStorageFind(String fingerprint, String count) { + return generatedTusEventKey("url-storage-find", fingerprint, count); + } + + private static String generatedTusEventKeyUrlStorageRemove(String urlStorageKey) { + return generatedTusEventKey("url-storage-remove", urlStorageKey); + } + private TusUploader uploaderFor(TusClient client, GeneratedTusRuntimeEventCase testCase) throws Exception { GeneratedTusRuntimeBeforeStartAction resumeAction = resumeBeforeStartAction(testCase); From ffb782977990be95c47360f5c187c9efd9be555c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:30:04 +0200 Subject: [PATCH 58/96] Use generic TUS extra event matching policy --- ...eneratedTusClientConformanceScenarios.java | 22 +++++++++---------- .../TestGeneratedTusConformanceEvents.java | 22 +++++++++---------- .../client/TestGeneratedTusRuntimeEvents.java | 18 +++++++-------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index ff168996..d89c5a17 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -34,7 +34,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -81,7 +81,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -123,7 +123,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -177,7 +177,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -218,7 +218,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -273,7 +273,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -692,7 +692,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -743,7 +743,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -962,7 +962,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" @@ -1007,7 +1007,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" @@ -1105,7 +1105,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java index 87ec23b6..f57659e5 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -21,7 +21,7 @@ public class TestGeneratedTusConformanceEvents { "singleUploadLifecycle", "singleUploadLifecycle", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -54,7 +54,7 @@ public class TestGeneratedTusConformanceEvents { "creationWithUpload", "creationWithUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -81,7 +81,7 @@ public class TestGeneratedTusConformanceEvents { "creationWithUpload", "creationWithUploadPartialChunk", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -122,7 +122,7 @@ public class TestGeneratedTusConformanceEvents { "protocolVersionSelection", "ietfDraft05CreationWithUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -149,7 +149,7 @@ public class TestGeneratedTusConformanceEvents { "protocolVersionSelection", "ietfDraft05ChunkedUploadComplete", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -190,7 +190,7 @@ public class TestGeneratedTusConformanceEvents { "protocolVersionSelection", "ietfDraft03ResumeWithoutKnownLength", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -219,7 +219,7 @@ public class TestGeneratedTusConformanceEvents { "resumeUpload", "resumeFromPreviousUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -256,7 +256,7 @@ public class TestGeneratedTusConformanceEvents { "relativeLocationResolution", "relativeLocationResolution", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -390,7 +390,7 @@ public class TestGeneratedTusConformanceEvents { "deferredLengthUpload", "deferredLengthUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" @@ -419,7 +419,7 @@ public class TestGeneratedTusConformanceEvents { "deferredLengthUpload", "deferredLengthChunkedUpload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" @@ -472,7 +472,7 @@ public class TestGeneratedTusConformanceEvents { "parallelUploadConcat", "parallelUploadConcat", new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index e6725a56..7da0b0de 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -32,7 +32,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "singleUploadLifecycle", new GeneratedTusRuntimeEventExpectations( new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress" + "exact-except-allowed-extra-events" ), new String[] { "progress:0:11", @@ -117,7 +117,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "resumeFromPreviousUpload", new GeneratedTusRuntimeEventExpectations( new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress" + "exact-except-allowed-extra-events" ), new String[] { "progress:5:11", @@ -201,7 +201,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "relativeLocationResolution", new GeneratedTusRuntimeEventExpectations( new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress" + "exact-except-allowed-extra-events" ), new String[] { "progress:0:11", @@ -286,7 +286,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "deferredLengthUpload", new GeneratedTusRuntimeEventExpectations( new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress" + "exact-except-allowed-extra-events" ), new String[] { "progress:0:11", @@ -375,7 +375,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "deferredLengthChunkedUpload", new GeneratedTusRuntimeEventExpectations( new GeneratedTusRuntimeEventPolicy( - "exact-except-extra-progress" + "exact-except-allowed-extra-events" ), new String[] { "progress:0:null", @@ -879,8 +879,8 @@ private void assertEvents(GeneratedTusRuntimeEventCase testCase, List ev return; } - if ("exact-except-extra-progress".equals(testCase.eventExpectations.policy.matching)) { - assertEventsExactExceptExtraProgress(testCase, events); + if ("exact-except-allowed-extra-events".equals(testCase.eventExpectations.policy.matching)) { + assertEventsExactExceptAllowedExtraEvents(testCase, events); return; } @@ -891,7 +891,7 @@ private void assertEvents(GeneratedTusRuntimeEventCase testCase, List ev + testCase.scenarioId); } - private void assertEventsExactExceptExtraProgress( + private void assertEventsExactExceptAllowedExtraEvents( GeneratedTusRuntimeEventCase testCase, List events) { int expectedIndex = 0; @@ -909,7 +909,7 @@ && eventMatchesExpected(testCase, event, expectedIndex)) { throw new AssertionError( testCase.scenarioId - + " emitted unexpected non-progress event " + + " emitted unexpected non-allowed extra event " + event + "; expected " + java.util.Arrays.toString(testCase.eventExpectations.keys)); From f9ed16a2b8914fb655f8e4a86edfc89b881c279f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 06:46:28 +0200 Subject: [PATCH 59/96] Regenerate Java TUS event key helpers --- .../io/tus/java/client/TestGeneratedTusRuntimeEvents.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 7da0b0de..6814617a 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -599,12 +599,10 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) } } - private static String generatedTusEventKey(String kind, String... parts) { - if (parts.length == 0) { - return kind; - } + private static final String generatedTusEventKeyPartSeparator = ":"; - return kind + ":" + String.join(":", parts); + private static String generatedTusEventKey(String... parts) { + return String.join(generatedTusEventKeyPartSeparator, parts); } private static String generatedTusEventKeyNumber(long value) { From 652b6a065902c75fe56f83b0bdffb4387ecc56ae Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 06:55:18 +0200 Subject: [PATCH 60/96] Use Java constant naming for generated TUS events --- .../io/tus/java/client/TestGeneratedTusRuntimeEvents.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 6814617a..2a50c44f 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -599,10 +599,10 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) } } - private static final String generatedTusEventKeyPartSeparator = ":"; + private static final String GENERATED_TUS_EVENT_KEY_PART_SEPARATOR = ":"; private static String generatedTusEventKey(String... parts) { - return String.join(generatedTusEventKeyPartSeparator, parts); + return String.join(GENERATED_TUS_EVENT_KEY_PART_SEPARATOR, parts); } private static String generatedTusEventKeyNumber(long value) { From 494059b51acf051577b60e45be4a29544c7b8d9d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:10:45 +0200 Subject: [PATCH 61/96] Flatten generated TUS completion fixtures --- ...eneratedTusClientConformanceScenarios.java | 228 ++++++------------ .../client/GeneratedTusProtocolContract.java | 19 +- 2 files changed, 80 insertions(+), 167 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index d89c5a17..12df5164 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -14,10 +14,8 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] { new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "single-upload-lifecycle", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "singleUploadLifecycle", "singleUploadLifecycle", new String[] { @@ -66,10 +64,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "creationWithUpload", "creationWithUpload", new String[] { @@ -107,10 +103,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload-partial-chunk", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "creationWithUpload", "creationWithUploadPartialChunk", new String[] { @@ -163,10 +157,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "protocolVersionSelection", "ietfDraft05CreationWithUpload", new String[] { @@ -203,10 +195,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "protocolVersionSelection", "ietfDraft05ChunkedUploadComplete", new String[] { @@ -258,10 +248,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "protocolVersionSelection", "ietfDraft03ResumeWithoutKnownLength", new String[] { @@ -301,10 +289,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "missingInput" - ), + "error", + "missingInput", "startOptionValidation", "startValidationMissingInput", new String[0], @@ -325,10 +311,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "missingEndpointOrUploadUrl" - ), + "error", + "missingEndpointOrUploadUrl", "startOptionValidation", "startValidationMissingEndpointOrUploadUrl", new String[0], @@ -349,10 +333,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "unsupportedProtocol" - ), + "error", + "unsupportedProtocol", "startOptionValidation", "startValidationUnsupportedProtocol", new String[0], @@ -373,10 +355,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "retryDelaysNotArray" - ), + "error", + "retryDelaysNotArray", "startOptionValidation", "startValidationRetryDelaysNotArray", new String[0], @@ -397,10 +377,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithUploadUrl" - ), + "error", + "parallelUploadsWithUploadUrl", "startOptionValidation", "startValidationParallelUploadsWithUploadUrl", new String[0], @@ -421,10 +399,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithUploadSize" - ), + "error", + "parallelUploadsWithUploadSize", "startOptionValidation", "startValidationParallelUploadsWithUploadSize", new String[0], @@ -445,10 +421,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithDeferredLength" - ), + "error", + "parallelUploadsWithDeferredLength", "startOptionValidation", "startValidationParallelUploadsWithDeferredLength", new String[0], @@ -469,10 +443,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "parallelUploadsWithUploadDataDuringCreation" - ), + "error", + "parallelUploadsWithUploadDataDuringCreation", "startOptionValidation", "startValidationParallelUploadsWithUploadDataDuringCreation", new String[0], @@ -493,10 +465,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "parallelBoundariesWithoutParallelUploads" - ), + "error", + "parallelBoundariesWithoutParallelUploads", "startOptionValidation", "startValidationParallelBoundariesWithoutParallelUploads", new String[0], @@ -517,10 +487,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "parallelBoundariesLengthMismatch" - ), + "error", + "parallelBoundariesLengthMismatch", "startOptionValidation", "startValidationParallelBoundariesLengthMismatch", new String[0], @@ -541,10 +509,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "detailed-error", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "unexpectedCreateResponse" - ), + "error", + "unexpectedCreateResponse", "detailedErrors", "detailedCreateResponseError", new String[] { @@ -567,10 +533,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "detailed-error", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "error", - "createUploadRequestFailed" - ), + "error", + "createUploadRequestFailed", "detailedErrors", "detailedCreateRequestError", new String[] { @@ -593,10 +557,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "uploadBodyHeaders", "uploadBodyHeaders", new String[] { @@ -620,10 +582,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "custom-request-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "customRequestHeaders", "customRequestHeaders", new String[] { @@ -647,10 +607,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "request-id-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "requestIdHeaders", "requestIdHeaders", new String[] { @@ -675,10 +633,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "resume-from-previous-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "resumeUpload", "resumeFromPreviousUpload", new String[] { @@ -728,10 +684,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "relative-location-resolution", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "relativeLocationResolution", "relativeLocationResolution", new String[] { @@ -771,10 +725,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "array-buffer-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "inputSources", "arrayBufferInput", new String[] { @@ -806,10 +758,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "array-buffer-view-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "inputSources", "arrayBufferViewInput", new String[] { @@ -841,10 +791,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "web-readable-stream-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "inputSources", "webReadableStreamInput", new String[] { @@ -876,10 +824,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "node-readable-stream-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "inputSources", "nodeReadableStreamInput", new String[] { @@ -911,10 +857,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "node-path-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "inputSources", "nodePathInput", new String[] { @@ -946,10 +890,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "deferred-length-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "deferredLengthUpload", "deferredLengthUpload", new String[] { @@ -990,10 +932,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "deferred-length-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "deferredLengthUpload", "deferredLengthChunkedUpload", new String[] { @@ -1059,10 +999,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "override-patch-method", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "overridePatchMethod", "overridePatchMethod", new String[] { @@ -1086,10 +1024,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "parallel-upload-concat", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "parallelUploadConcat", "parallelUploadConcat", new String[] { @@ -1129,10 +1065,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "parallel-upload-abort-cleanup", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "aborted", - null - ), + "aborted", + null, "parallelUploadConcat", "parallelUploadAbortCleanup", new String[] { @@ -1166,10 +1100,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "retry-patch-after-offset-recovery", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "retryOffsetRecovery", "retryPatchAfterOffsetRecovery", new String[] { @@ -1208,10 +1140,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "request-lifecycle-hooks", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "success", - null - ), + "success", + null, "requestLifecycleHooks", "requestLifecycleHooks", new String[] { @@ -1244,10 +1174,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "abort-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "aborted", - null - ), + "aborted", + null, "abortUpload", "abortUpload", new String[] { @@ -1274,10 +1202,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "abort-upload-after-stored-url", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "aborted", - null - ), + "aborted", + null, "abortUpload", "abortUploadAfterStoredUrl", new String[] { @@ -1307,10 +1233,8 @@ final class GeneratedTusClientConformanceScenarios { ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "terminate-with-retry", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( - "terminated", - null - ), + "terminated", + null, "terminateUpload", "terminateWithRetry", new String[] { diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 729c4464..49539571 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1587,15 +1587,16 @@ static final class GeneratedTusClientConformanceScenario { GeneratedTusClientConformanceScenario( String behavior, - GeneratedTusClientConformanceCompletion completion, + String completionKind, + String completionReason, String featureId, String scenarioId, String[] operationIds, String[] primitives, GeneratedTusClientConformanceEvents events) { this.behavior = behavior; - this.completionKind = completion.kind; - this.completionReason = completion.reason; + this.completionKind = completionKind; + this.completionReason = completionReason; this.featureId = featureId; this.scenarioId = scenarioId; this.operationIds = operationIds; @@ -1649,16 +1650,4 @@ static final class GeneratedTusClientConformanceEventPolicy { } } - /** - * Generated client conformance completion fixture. - */ - static final class GeneratedTusClientConformanceCompletion { - final String kind; - final String reason; - - GeneratedTusClientConformanceCompletion(String kind, String reason) { - this.kind = kind; - this.reason = reason; - } - } } From 5886843cbf2b406228af66ca4df492e649ca3595 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:36:35 +0200 Subject: [PATCH 62/96] Regenerate lintable TUS Java fixtures --- ...eneratedTusClientConformanceScenarios.java | 456 ++++++++++-------- .../client/GeneratedTusProtocolContract.java | 40 +- 2 files changed, 296 insertions(+), 200 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java index 12df5164..06571911 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -13,11 +13,13 @@ final class GeneratedTusClientConformanceScenarios { static final GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] { new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "single-upload-lifecycle", - "success", - null, - "singleUploadLifecycle", - "singleUploadLifecycle", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "single-upload-lifecycle", + "success", + null, + "singleUploadLifecycle", + "singleUploadLifecycle" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -63,11 +65,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "creation-with-upload", - "success", - null, - "creationWithUpload", - "creationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload", + "success", + null, + "creationWithUpload", + "creationWithUpload" + ), new String[] { "createTusUpload", }, @@ -102,11 +106,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "creation-with-upload-partial-chunk", - "success", - null, - "creationWithUpload", - "creationWithUploadPartialChunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload-partial-chunk", + "success", + null, + "creationWithUpload", + "creationWithUploadPartialChunk" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -156,11 +162,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "creation-with-upload", - "success", - null, - "protocolVersionSelection", - "ietfDraft05CreationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload", + "success", + null, + "protocolVersionSelection", + "ietfDraft05CreationWithUpload" + ), new String[] { "createTusUpload", }, @@ -194,11 +202,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "upload-body-headers", - "success", - null, - "protocolVersionSelection", - "ietfDraft05ChunkedUploadComplete", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", + "success", + null, + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete" + ), new String[] { "getTusUploadOffset", "patchTusUpload", @@ -247,11 +257,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "upload-body-headers", - "success", - null, - "protocolVersionSelection", - "ietfDraft03ResumeWithoutKnownLength", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", + "success", + null, + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength" + ), new String[] { "getTusUploadOffset", "patchTusUpload", @@ -288,11 +300,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "missingInput", - "startOptionValidation", - "startValidationMissingInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "missingInput", + "startOptionValidation", + "startValidationMissingInput" + ), new String[0], new String[] { "validate-start-options", @@ -310,11 +324,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "missingEndpointOrUploadUrl", - "startOptionValidation", - "startValidationMissingEndpointOrUploadUrl", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "missingEndpointOrUploadUrl", + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl" + ), new String[0], new String[] { "validate-start-options", @@ -332,11 +348,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "unsupportedProtocol", - "startOptionValidation", - "startValidationUnsupportedProtocol", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "unsupportedProtocol", + "startOptionValidation", + "startValidationUnsupportedProtocol" + ), new String[0], new String[] { "validate-start-options", @@ -354,11 +372,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "retryDelaysNotArray", - "startOptionValidation", - "startValidationRetryDelaysNotArray", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "retryDelaysNotArray", + "startOptionValidation", + "startValidationRetryDelaysNotArray" + ), new String[0], new String[] { "validate-start-options", @@ -376,11 +396,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "parallelUploadsWithUploadUrl", - "startOptionValidation", - "startValidationParallelUploadsWithUploadUrl", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithUploadUrl", + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl" + ), new String[0], new String[] { "validate-start-options", @@ -398,11 +420,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "parallelUploadsWithUploadSize", - "startOptionValidation", - "startValidationParallelUploadsWithUploadSize", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithUploadSize", + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize" + ), new String[0], new String[] { "validate-start-options", @@ -420,11 +444,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "parallelUploadsWithDeferredLength", - "startOptionValidation", - "startValidationParallelUploadsWithDeferredLength", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithDeferredLength", + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength" + ), new String[0], new String[] { "validate-start-options", @@ -442,11 +468,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "parallelUploadsWithUploadDataDuringCreation", - "startOptionValidation", - "startValidationParallelUploadsWithUploadDataDuringCreation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithUploadDataDuringCreation", + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation" + ), new String[0], new String[] { "validate-start-options", @@ -464,11 +492,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "parallelBoundariesWithoutParallelUploads", - "startOptionValidation", - "startValidationParallelBoundariesWithoutParallelUploads", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelBoundariesWithoutParallelUploads", + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads" + ), new String[0], new String[] { "validate-start-options", @@ -486,11 +516,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - "error", - "parallelBoundariesLengthMismatch", - "startOptionValidation", - "startValidationParallelBoundariesLengthMismatch", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelBoundariesLengthMismatch", + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch" + ), new String[0], new String[] { "validate-start-options", @@ -508,11 +540,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "detailed-error", - "error", - "unexpectedCreateResponse", - "detailedErrors", - "detailedCreateResponseError", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "detailed-error", + "error", + "unexpectedCreateResponse", + "detailedErrors", + "detailedCreateResponseError" + ), new String[] { "createTusUpload", }, @@ -532,11 +566,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "detailed-error", - "error", - "createUploadRequestFailed", - "detailedErrors", - "detailedCreateRequestError", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "detailed-error", + "error", + "createUploadRequestFailed", + "detailedErrors", + "detailedCreateRequestError" + ), new String[] { "createTusUpload", }, @@ -556,11 +592,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "upload-body-headers", - "success", - null, - "uploadBodyHeaders", - "uploadBodyHeaders", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", + "success", + null, + "uploadBodyHeaders", + "uploadBodyHeaders" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -581,11 +619,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "custom-request-headers", - "success", - null, - "customRequestHeaders", - "customRequestHeaders", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "custom-request-headers", + "success", + null, + "customRequestHeaders", + "customRequestHeaders" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -606,11 +646,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "request-id-headers", - "success", - null, - "requestIdHeaders", - "requestIdHeaders", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "request-id-headers", + "success", + null, + "requestIdHeaders", + "requestIdHeaders" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -632,11 +674,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "resume-from-previous-upload", - "success", - null, - "resumeUpload", - "resumeFromPreviousUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "resume-from-previous-upload", + "success", + null, + "resumeUpload", + "resumeFromPreviousUpload" + ), new String[] { "getTusUploadOffset", "patchTusUpload", @@ -683,11 +727,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "relative-location-resolution", - "success", - null, - "relativeLocationResolution", - "relativeLocationResolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "relative-location-resolution", + "success", + null, + "relativeLocationResolution", + "relativeLocationResolution" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -724,11 +770,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "array-buffer-input", - "success", - null, - "inputSources", - "arrayBufferInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "array-buffer-input", + "success", + null, + "inputSources", + "arrayBufferInput" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -757,11 +805,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "array-buffer-view-input", - "success", - null, - "inputSources", - "arrayBufferViewInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "array-buffer-view-input", + "success", + null, + "inputSources", + "arrayBufferViewInput" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -790,11 +840,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "web-readable-stream-input", - "success", - null, - "inputSources", - "webReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "web-readable-stream-input", + "success", + null, + "inputSources", + "webReadableStreamInput" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -823,11 +875,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "node-readable-stream-input", - "success", - null, - "inputSources", - "nodeReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "node-readable-stream-input", + "success", + null, + "inputSources", + "nodeReadableStreamInput" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -856,11 +910,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "node-path-input", - "success", - null, - "inputSources", - "nodePathInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "node-path-input", + "success", + null, + "inputSources", + "nodePathInput" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -889,11 +945,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "deferred-length-upload", - "success", - null, - "deferredLengthUpload", - "deferredLengthUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "deferred-length-upload", + "success", + null, + "deferredLengthUpload", + "deferredLengthUpload" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -931,11 +989,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "deferred-length-upload", - "success", - null, - "deferredLengthUpload", - "deferredLengthChunkedUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "deferred-length-upload", + "success", + null, + "deferredLengthUpload", + "deferredLengthChunkedUpload" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -998,11 +1058,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "override-patch-method", - "success", - null, - "overridePatchMethod", - "overridePatchMethod", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "override-patch-method", + "success", + null, + "overridePatchMethod", + "overridePatchMethod" + ), new String[] { "getTusUploadOffset", "patchTusUpload", @@ -1023,11 +1085,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "parallel-upload-concat", - "success", - null, - "parallelUploadConcat", - "parallelUploadConcat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "parallel-upload-concat", + "success", + null, + "parallelUploadConcat", + "parallelUploadConcat" + ), new String[] { "createTusUpload", "createTusUpload", @@ -1064,11 +1128,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "parallel-upload-abort-cleanup", - "aborted", - null, - "parallelUploadConcat", - "parallelUploadAbortCleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "parallel-upload-abort-cleanup", + "aborted", + null, + "parallelUploadConcat", + "parallelUploadAbortCleanup" + ), new String[] { "createTusUpload", "createTusUpload", @@ -1099,11 +1165,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "retry-patch-after-offset-recovery", - "success", - null, - "retryOffsetRecovery", - "retryPatchAfterOffsetRecovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "retry-patch-after-offset-recovery", + "success", + null, + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -1139,11 +1207,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "request-lifecycle-hooks", - "success", - null, - "requestLifecycleHooks", - "requestLifecycleHooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "request-lifecycle-hooks", + "success", + null, + "requestLifecycleHooks", + "requestLifecycleHooks" + ), new String[] { "getTusUploadOffset", }, @@ -1173,11 +1243,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "abort-upload", - "aborted", - null, - "abortUpload", - "abortUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "abort-upload", + "aborted", + null, + "abortUpload", + "abortUpload" + ), new String[] { "createTusUpload", }, @@ -1201,11 +1273,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "abort-upload-after-stored-url", - "aborted", - null, - "abortUpload", - "abortUploadAfterStoredUrl", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "abort-upload-after-stored-url", + "aborted", + null, + "abortUpload", + "abortUploadAfterStoredUrl" + ), new String[] { "createTusUpload", "patchTusUpload", @@ -1232,11 +1306,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "terminate-with-retry", - "terminated", - null, - "terminateUpload", - "terminateWithRetry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "terminate-with-retry", + "terminated", + null, + "terminateUpload", + "terminateWithRetry" + ), new String[] { "createTusUpload", "patchTusUpload", diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 49539571..30923d32 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1569,6 +1569,30 @@ static final class GeneratedTusManagedUploadProofCase { } } + /** + * Generated client conformance scenario metadata. + */ + static final class GeneratedTusClientConformanceScenarioMetadata { + final String behavior; + final String completionKind; + final String completionReason; + final String featureId; + final String scenarioId; + + GeneratedTusClientConformanceScenarioMetadata( + String behavior, + String completionKind, + String completionReason, + String featureId, + String scenarioId) { + this.behavior = behavior; + this.completionKind = completionKind; + this.completionReason = completionReason; + this.featureId = featureId; + this.scenarioId = scenarioId; + } + } + /** * Generated client conformance scenario fixture. */ @@ -1586,19 +1610,15 @@ static final class GeneratedTusClientConformanceScenario { final String[] eventKeyExtraPrefixes; GeneratedTusClientConformanceScenario( - String behavior, - String completionKind, - String completionReason, - String featureId, - String scenarioId, + GeneratedTusClientConformanceScenarioMetadata metadata, String[] operationIds, String[] primitives, GeneratedTusClientConformanceEvents events) { - this.behavior = behavior; - this.completionKind = completionKind; - this.completionReason = completionReason; - this.featureId = featureId; - this.scenarioId = scenarioId; + this.behavior = metadata.behavior; + this.completionKind = metadata.completionKind; + this.completionReason = metadata.completionReason; + this.featureId = metadata.featureId; + this.scenarioId = metadata.scenarioId; this.operationIds = operationIds; this.primitives = primitives; this.eventPolicy = events.policy; From 1dd682a3853e0b13c027adfcd3db67ddfd65c8c2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:32:01 +0200 Subject: [PATCH 63/96] Update managed upload runtime capabilities fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 403 +++++++++--------- 1 file changed, 202 insertions(+), 201 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 3bbb9ccd..248ba8ba 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -39,17 +39,13 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadRuntimeCase[] { new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadDurableRetry", - "java", - "process-lifetime-worker-pool", - "copy-to-owned-storage", - "available", - "filesystem", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadDurableRetry" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false ), new GeneratedTusManagedUploadTransport( "Location" @@ -57,12 +53,22 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadOutcome( "terminal", "succeeded", - "", "" ), - new GeneratedTusManagedUploadCleanup( - "remove-owned-source-after-success", - "remove-after-success" + new GeneratedTusManagedUploadExecution( + true, + false, + false, + false, + true, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + false, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -218,17 +224,13 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadPermanentFailure", - "java", - "process-lifetime-worker-pool", - "copy-to-owned-storage", - "available", - "filesystem", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadPermanentFailure" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false ), new GeneratedTusManagedUploadTransport( "Location" @@ -236,12 +238,22 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadOutcome( "terminal", "failed", - "unretryable-protocol-error", "" ), - new GeneratedTusManagedUploadCleanup( - "retain-owned-source-after-permanent-failure", - "absent-after-permanent-failure" + new GeneratedTusManagedUploadExecution( + false, + false, + false, + true, + true, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -302,17 +314,13 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadRetryPolicyExhausted", - "java", - "process-lifetime-worker-pool", - "copy-to-owned-storage", - "available", - "filesystem", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadRetryPolicyExhausted" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false ), new GeneratedTusManagedUploadTransport( "Location" @@ -320,12 +328,22 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadOutcome( "terminal", "failed", - "retry-policy-exhausted", "" ), - new GeneratedTusManagedUploadCleanup( - "retain-owned-source-after-permanent-failure", - "absent-after-permanent-failure" + new GeneratedTusManagedUploadExecution( + false, + false, + true, + true, + true, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -461,17 +479,13 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadSourceUnavailable", - "java", - "process-lifetime-worker-pool", - "copy-to-owned-storage", - "missing-before-durable-copy", - "filesystem", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadSourceUnavailable" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false ), new GeneratedTusManagedUploadTransport( "Location" @@ -479,12 +493,22 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadOutcome( "terminal", "failed", - "source-unavailable", "" ), - new GeneratedTusManagedUploadCleanup( - "absent-after-source-unavailable", - "absent-after-permanent-failure" + new GeneratedTusManagedUploadExecution( + false, + false, + true, + false, + true, + false, + true, + true + ), + new GeneratedTusManagedUploadStateExpectations( + false, + false, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -623,25 +647,19 @@ private void assertTerminalResult( private void assertTerminalFailure( GeneratedTusManagedUploadRuntimeCase testCase, Throwable error) { - if ("unretryable-protocol-error".equals(testCase.outcomeFailure)) { + if (testCase.expectProtocolExceptionOnTerminalFailure && error instanceof ProtocolException) { assertTrue(testCase.scenarioId, error instanceof ProtocolException); return; } - if ("source-unavailable".equals(testCase.outcomeFailure)) { + if (testCase.expectIoExceptionOnTerminalFailure && error instanceof IOException) { assertTrue(testCase.scenarioId, error instanceof IOException); return; } - if ("retry-policy-exhausted".equals(testCase.outcomeFailure)) { - assertTrue( - testCase.scenarioId, - error instanceof ProtocolException || error instanceof IOException); - return; - } throw new AssertionError( testCase.scenarioId - + " uses unsupported generated terminal failure " - + testCase.outcomeFailure); + + " observed unexpected generated terminal failure " + + error); } private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { @@ -649,30 +667,12 @@ private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) !"deferred".equals(testCase.outcomeKind) || !"pending".equals(testCase.outcomeState) || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) - || !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) - || networkConstraintSatisfied(testCase)) { + || !testCase.deferBeforeProtocol + || testCase.networkConstraintSatisfied) { throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); } } - private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { - if ("offline".equals(testCase.currentNetwork)) { - return false; - } - if ("any-network".equals(testCase.networkRequired)) { - return "metered-network".equals(testCase.currentNetwork) - || "unmetered-network".equals(testCase.currentNetwork); - } - if ("unmetered-network".equals(testCase.networkRequired)) { - return "unmetered-network".equals(testCase.currentNetwork); - } - - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated network requirement " - + testCase.networkRequired); - } - private TusExecutor managedExecutorFor( final GeneratedTusManagedUploadRuntimeCase testCase, final TusClient client, @@ -756,11 +756,10 @@ private void copyDurableSource( GeneratedTusManagedUploadRuntimeCase testCase, File source, File ownedSource) throws IOException { - if (!"copy-to-owned-storage".equals(testCase.sourceDurability)) { + if (!testCase.copySourceToOwnedStorage) { throw new AssertionError( testCase.scenarioId - + " uses unsupported generated source durability " - + testCase.sourceDurability); + + " uses unsupported generated source durability capability"); } Files.copy(source.toPath(), ownedSource.toPath(), StandardCopyOption.REPLACE_EXISTING); @@ -773,11 +772,11 @@ private void prepareSourceBeforeProtocol( File ownedSource, List states, File stateFile) throws IOException { - if ("available".equals(testCase.sourceAvailability)) { + if (testCase.prepareDurableSourceBeforeProtocol) { copyDurableSource(testCase, source, ownedSource); return; } - if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + if (testCase.simulateMissingSourceBeforeDurableCopy) { GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; if (source.exists() && !source.delete()) { throw new IOException("Could not remove generated input source " + source); @@ -794,23 +793,21 @@ private void prepareSourceBeforeProtocol( throw new AssertionError( testCase.scenarioId - + " uses unsupported generated source availability " - + testCase.sourceAvailability); + + " uses unsupported generated source preparation expectations"); } private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { - return "source-unavailable".equals(testCase.outcomeFailure) - && "missing-before-durable-copy".equals(testCase.sourceAvailability); + return testCase.sourceUnavailableBeforeProtocol; } private boolean shouldDeferBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { - return "defer-until-network-constraint-satisfied".equals(testCase.networkDecision); + return testCase.deferBeforeProtocol; } private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { - if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + if (!testCase.cleanupOwnedSourceAfterTerminalState) { return; } @@ -820,58 +817,36 @@ private void cleanupAfterTerminalState( private void assertOwnedSourceState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) { - if ("remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { - assertFalse(testCase.scenarioId, ownedSource.exists()); - return; - } - if ("retain-owned-source-after-permanent-failure".equals(testCase.ownedSourceCleanup)) { + if (testCase.expectOwnedSourceExists) { assertTrue(testCase.scenarioId, ownedSource.exists()); ownedSource.delete(); return; } - if ("retain-owned-source-while-deferred".equals(testCase.ownedSourceCleanup)) { - assertTrue(testCase.scenarioId, ownedSource.exists()); - ownedSource.delete(); - return; - } - if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { - assertFalse(testCase.scenarioId, ownedSource.exists()); - return; - } - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated owned-source cleanup " - + testCase.ownedSourceCleanup); + assertFalse(testCase.scenarioId, ownedSource.exists()); } private void assertInputSourceState( GeneratedTusManagedUploadRuntimeCase testCase, File source) { - if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { - assertFalse(testCase.scenarioId, source.exists()); + if (testCase.expectInputSourceExists) { + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); return; } - assertTrue(testCase.scenarioId, source.exists()); - source.delete(); + assertFalse(testCase.scenarioId, source.exists()); } private void assertResumeUrlState( GeneratedTusManagedUploadRuntimeCase testCase, GeneratedTusManagedUploadUrlStore urlStore) { - if ( - "remove-after-success".equals(testCase.resumeUrlCleanup) - || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup) - || "absent-while-deferred".equals(testCase.resumeUrlCleanup)) { - assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + if (testCase.expectResumeUrlExists) { + assertTrue(testCase.scenarioId, urlStore.get(testCase.input.fingerprint) != null); return; } - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated resume URL cleanup " - + testCase.resumeUrlCleanup); + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); } private void assertProtocolRequestCount(GeneratedTusManagedUploadRuntimeCase testCase) { @@ -894,11 +869,10 @@ private void recordState( List states, File stateFile, String state) throws IOException { - if (!"filesystem".equals(testCase.stateBackend)) { + if (!testCase.useFilesystemStateBackend) { throw new AssertionError( testCase.scenarioId - + " uses unsupported generated state backend " - + testCase.stateBackend); + + " uses unsupported generated state backend capability"); } states.add(state); @@ -1023,21 +997,25 @@ private static String offsetDiscoveryMethod() { private static final class GeneratedTusManagedUploadRuntimeCase { final String scenarioId; - final String runtime; - final String scheduler; - final String sourceDurability; - final String sourceAvailability; - final String stateBackend; - final String networkRequired; - final String currentNetwork; - final String networkDecision; + final boolean copySourceToOwnedStorage; + final boolean useDurableOsScheduler; + final boolean useFilesystemStateBackend; + final boolean usePlatformKeyValueStateBackend; final String locationHeaderName; final String outcomeKind; final String outcomeState; - final String outcomeFailure; final String outcomeReason; - final String ownedSourceCleanup; - final String resumeUrlCleanup; + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean deferBeforeProtocol; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + final boolean networkConstraintSatisfied; + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + final boolean expectInputSourceExists; + final boolean expectOwnedSourceExists; + final boolean expectResumeUrlExists; final String[] expectedStates; final int[] retryDelays; final String offsetDiscoveryMethod; @@ -1046,28 +1024,35 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, + GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, GeneratedTusManagedUploadTransport transport, GeneratedTusManagedUploadOutcome outcome, - GeneratedTusManagedUploadCleanup cleanup, + GeneratedTusManagedUploadExecution execution, + GeneratedTusManagedUploadStateExpectations stateExpectations, GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, GeneratedTusManagedUploadAttempt[] attempts) { this.scenarioId = profile.scenarioId; - this.runtime = profile.runtime; - this.scheduler = profile.scheduler; - this.sourceDurability = profile.sourceDurability; - this.sourceAvailability = profile.sourceAvailability; - this.stateBackend = profile.stateBackend; - this.networkRequired = profile.networkRequired; - this.currentNetwork = profile.currentNetwork; - this.networkDecision = profile.networkDecision; + this.copySourceToOwnedStorage = runtimeCapabilities.copySourceToOwnedStorage; + this.useDurableOsScheduler = runtimeCapabilities.useDurableOsScheduler; + this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; + this.usePlatformKeyValueStateBackend = + runtimeCapabilities.usePlatformKeyValueStateBackend; this.locationHeaderName = transport.locationHeaderName; this.outcomeKind = outcome.kind; this.outcomeState = outcome.state; - this.outcomeFailure = outcome.failure; this.outcomeReason = outcome.reason; - this.ownedSourceCleanup = cleanup.ownedSource; - this.resumeUrlCleanup = cleanup.resumeUrl; + this.cleanupOwnedSourceAfterTerminalState = execution.cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = execution.deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = execution.expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = execution.expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = execution.networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = execution.prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = execution.simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = execution.sourceUnavailableBeforeProtocol; + this.expectInputSourceExists = stateExpectations.inputSourceExists; + this.expectOwnedSourceExists = stateExpectations.ownedSourceExists; + this.expectResumeUrlExists = stateExpectations.resumeUrlExists; this.expectedStates = retryPlan.expectedStates; this.retryDelays = retryPlan.retryDelays; this.offsetDiscoveryMethod = offsetDiscoveryMethod(); @@ -1079,57 +1064,38 @@ private static final class GeneratedTusManagedUploadRuntimeCase { private static final class GeneratedTusManagedUploadOutcome { final String kind; final String state; - final String failure; final String reason; - GeneratedTusManagedUploadOutcome(String kind, String state, String failure, String reason) { + GeneratedTusManagedUploadOutcome(String kind, String state, String reason) { this.kind = kind; this.state = state; - this.failure = failure; this.reason = reason; } } private static final class GeneratedTusManagedUploadRuntimeProfile { final String scenarioId; - final String runtime; - final String scheduler; - final String sourceDurability; - final String sourceAvailability; - final String stateBackend; - final String networkRequired; - final String currentNetwork; - final String networkDecision; - - GeneratedTusManagedUploadRuntimeProfile( - String scenarioId, - String runtime, - String scheduler, - String sourceDurability, - String sourceAvailability, - String stateBackend, - GeneratedTusManagedUploadNetwork network) { + + GeneratedTusManagedUploadRuntimeProfile(String scenarioId) { this.scenarioId = scenarioId; - this.runtime = runtime; - this.scheduler = scheduler; - this.sourceDurability = sourceDurability; - this.sourceAvailability = sourceAvailability; - this.stateBackend = stateBackend; - this.networkRequired = network.required; - this.currentNetwork = network.current; - this.networkDecision = network.decision; } } - private static final class GeneratedTusManagedUploadNetwork { - final String required; - final String current; - final String decision; - - GeneratedTusManagedUploadNetwork(String required, String current, String decision) { - this.required = required; - this.current = current; - this.decision = decision; + private static final class GeneratedTusManagedUploadRuntimeCapabilities { + final boolean copySourceToOwnedStorage; + final boolean useDurableOsScheduler; + final boolean useFilesystemStateBackend; + final boolean usePlatformKeyValueStateBackend; + + GeneratedTusManagedUploadRuntimeCapabilities( + boolean copySourceToOwnedStorage, + boolean useDurableOsScheduler, + boolean useFilesystemStateBackend, + boolean usePlatformKeyValueStateBackend) { + this.copySourceToOwnedStorage = copySourceToOwnedStorage; + this.useDurableOsScheduler = useDurableOsScheduler; + this.useFilesystemStateBackend = useFilesystemStateBackend; + this.usePlatformKeyValueStateBackend = usePlatformKeyValueStateBackend; } } @@ -1141,13 +1107,48 @@ private static final class GeneratedTusManagedUploadTransport { } } - private static final class GeneratedTusManagedUploadCleanup { - final String ownedSource; - final String resumeUrl; + private static final class GeneratedTusManagedUploadExecution { + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean deferBeforeProtocol; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + final boolean networkConstraintSatisfied; + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + + GeneratedTusManagedUploadExecution( + boolean cleanupOwnedSourceAfterTerminalState, + boolean deferBeforeProtocol, + boolean expectIoExceptionOnTerminalFailure, + boolean expectProtocolExceptionOnTerminalFailure, + boolean networkConstraintSatisfied, + boolean prepareDurableSourceBeforeProtocol, + boolean simulateMissingSourceBeforeDurableCopy, + boolean sourceUnavailableBeforeProtocol) { + this.cleanupOwnedSourceAfterTerminalState = cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; + } + } - GeneratedTusManagedUploadCleanup(String ownedSource, String resumeUrl) { - this.ownedSource = ownedSource; - this.resumeUrl = resumeUrl; + private static final class GeneratedTusManagedUploadStateExpectations { + final boolean inputSourceExists; + final boolean ownedSourceExists; + final boolean resumeUrlExists; + + GeneratedTusManagedUploadStateExpectations( + boolean inputSourceExists, + boolean ownedSourceExists, + boolean resumeUrlExists) { + this.inputSourceExists = inputSourceExists; + this.ownedSourceExists = ownedSourceExists; + this.resumeUrlExists = resumeUrlExists; } } From 1e511dce02aa7429b0c3c6634cc58059d94256a3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:39:12 +0200 Subject: [PATCH 64/96] Update managed upload outcome fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 86 +++++++++++-------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 248ba8ba..9dcb63dd 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -50,10 +50,11 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "succeeded", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + false, + true, + true ), new GeneratedTusManagedUploadExecution( true, @@ -235,10 +236,11 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "failed", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false ), new GeneratedTusManagedUploadExecution( false, @@ -325,10 +327,11 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "failed", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false ), new GeneratedTusManagedUploadExecution( false, @@ -490,10 +493,11 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "failed", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false ), new GeneratedTusManagedUploadExecution( false, @@ -626,18 +630,18 @@ public Boolean call() throws Exception { private void assertTerminalResult( GeneratedTusManagedUploadRuntimeCase testCase, Future future) throws Exception { - if (!"terminal".equals(testCase.outcomeKind)) { + if (!testCase.expectTerminalResult) { throw new AssertionError(testCase.scenarioId + " expected deferred outcome"); } try { boolean result = future.get(); - if (!"succeeded".equals(testCase.outcomeState)) { + if (!testCase.expectTerminalSuccess) { throw new AssertionError(testCase.scenarioId + " expected terminal failure"); } assertTrue(testCase.scenarioId, result); } catch (ExecutionException error) { - if (!"failed".equals(testCase.outcomeState)) { + if (!testCase.expectTerminalFailure) { throw error; } assertTerminalFailure(testCase, error.getCause()); @@ -664,9 +668,7 @@ private void assertTerminalFailure( private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { if ( - !"deferred".equals(testCase.outcomeKind) - || !"pending".equals(testCase.outcomeState) - || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) + !testCase.expectDeferredNetworkResult || !testCase.deferBeforeProtocol || testCase.networkConstraintSatisfied) { throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); @@ -1002,9 +1004,10 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final boolean useFilesystemStateBackend; final boolean usePlatformKeyValueStateBackend; final String locationHeaderName; - final String outcomeKind; - final String outcomeState; - final String outcomeReason; + final boolean expectDeferredNetworkResult; + final boolean expectTerminalFailure; + final boolean expectTerminalResult; + final boolean expectTerminalSuccess; final boolean cleanupOwnedSourceAfterTerminalState; final boolean deferBeforeProtocol; final boolean expectIoExceptionOnTerminalFailure; @@ -1026,7 +1029,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, GeneratedTusManagedUploadTransport transport, - GeneratedTusManagedUploadOutcome outcome, + GeneratedTusManagedUploadOutcomeExpectations outcomeExpectations, GeneratedTusManagedUploadExecution execution, GeneratedTusManagedUploadStateExpectations stateExpectations, GeneratedTusManagedUploadRetryPlan retryPlan, @@ -1039,9 +1042,10 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.usePlatformKeyValueStateBackend = runtimeCapabilities.usePlatformKeyValueStateBackend; this.locationHeaderName = transport.locationHeaderName; - this.outcomeKind = outcome.kind; - this.outcomeState = outcome.state; - this.outcomeReason = outcome.reason; + this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; + this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; + this.expectTerminalResult = outcomeExpectations.expectTerminalResult; + this.expectTerminalSuccess = outcomeExpectations.expectTerminalSuccess; this.cleanupOwnedSourceAfterTerminalState = execution.cleanupOwnedSourceAfterTerminalState; this.deferBeforeProtocol = execution.deferBeforeProtocol; this.expectIoExceptionOnTerminalFailure = execution.expectIoExceptionOnTerminalFailure; @@ -1061,15 +1065,21 @@ private static final class GeneratedTusManagedUploadRuntimeCase { } } - private static final class GeneratedTusManagedUploadOutcome { - final String kind; - final String state; - final String reason; - - GeneratedTusManagedUploadOutcome(String kind, String state, String reason) { - this.kind = kind; - this.state = state; - this.reason = reason; + private static final class GeneratedTusManagedUploadOutcomeExpectations { + final boolean expectDeferredNetworkResult; + final boolean expectTerminalFailure; + final boolean expectTerminalResult; + final boolean expectTerminalSuccess; + + GeneratedTusManagedUploadOutcomeExpectations( + boolean expectDeferredNetworkResult, + boolean expectTerminalFailure, + boolean expectTerminalResult, + boolean expectTerminalSuccess) { + this.expectDeferredNetworkResult = expectDeferredNetworkResult; + this.expectTerminalFailure = expectTerminalFailure; + this.expectTerminalResult = expectTerminalResult; + this.expectTerminalSuccess = expectTerminalSuccess; } } From 1dcfc3caab14850bfa2202db7ad76ce4c548d4ea Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:44:37 +0200 Subject: [PATCH 65/96] Update managed upload attempt fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 9dcb63dd..a1eca9b1 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -100,7 +100,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 0, "failed", new GeneratedTusManagedUploadFailure( - "after-accepted-offset", + true, + false, + false, "io-error", 7 ), @@ -282,7 +284,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 0, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "unretryable-protocol-error", -1 ), @@ -380,7 +384,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 0, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "retryable-protocol-error", -1 ), @@ -414,7 +420,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 1, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "retryable-protocol-error", -1 ), @@ -448,7 +456,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 2, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "retryable-protocol-error", -1 ), @@ -539,7 +549,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 0, "failed", new GeneratedTusManagedUploadFailure( - "before-protocol-request", + false, + true, + false, "source-unavailable", -1 ), @@ -702,7 +714,7 @@ protected void makeAttempt() throws ProtocolException, IOException { && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { uploader.finish(false); recordState(testCase, states, stateFile, attempt.stateAfterAttempt); - throw new IOException(attempt.failure.kind); + throw new IOException(attempt.failure.failureMessage); } } uploader.finish(); @@ -722,7 +734,7 @@ protected void makeAttempt() throws ProtocolException, IOException { private boolean isAfterAcceptedOffsetFailure(GeneratedTusManagedUploadAttempt attempt) { return attempt.failure != null - && "after-accepted-offset".equals(attempt.failure.phase); + && attempt.failure.failAfterAcceptedOffset; } private void recordDuringProtocolFailure( @@ -730,7 +742,7 @@ private void recordDuringProtocolFailure( List states, File stateFile, GeneratedTusManagedUploadAttempt attempt) throws IOException { - if (attempt.failure == null || !"during-protocol-request".equals(attempt.failure.phase)) { + if (attempt.failure == null || !attempt.failure.failDuringProtocolRequest) { return; } @@ -1212,14 +1224,23 @@ private static final class GeneratedTusManagedUploadAttempt { } private static final class GeneratedTusManagedUploadFailure { - final String phase; - final String kind; final long afterAcceptedOffset; - - GeneratedTusManagedUploadFailure(String phase, String kind, long afterAcceptedOffset) { - this.phase = phase; - this.kind = kind; + final boolean failAfterAcceptedOffset; + final boolean failBeforeProtocolRequest; + final boolean failDuringProtocolRequest; + final String failureMessage; + + GeneratedTusManagedUploadFailure( + boolean failAfterAcceptedOffset, + boolean failBeforeProtocolRequest, + boolean failDuringProtocolRequest, + String failureMessage, + long afterAcceptedOffset) { this.afterAcceptedOffset = afterAcceptedOffset; + this.failAfterAcceptedOffset = failAfterAcceptedOffset; + this.failBeforeProtocolRequest = failBeforeProtocolRequest; + this.failDuringProtocolRequest = failDuringProtocolRequest; + this.failureMessage = failureMessage; } } From 37177dce3a3fbc71ee321761a8c4dfea4529dbae Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:54:17 +0200 Subject: [PATCH 66/96] Update managed upload state fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index a1eca9b1..8889c894 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -48,6 +48,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { false ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -98,6 +99,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( true, @@ -167,6 +169,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), new GeneratedTusManagedUploadAttempt( 1, + "running", "succeeded", null, new GeneratedTusManagedUploadRequest[] { @@ -236,6 +239,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { false ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -282,6 +286,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -329,6 +334,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { false ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -382,6 +388,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -418,6 +425,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), new GeneratedTusManagedUploadAttempt( 1, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -454,6 +462,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), new GeneratedTusManagedUploadAttempt( 2, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -501,6 +510,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { false ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -547,6 +557,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -585,7 +596,7 @@ public void testManagedUploadDurableRetryRuntime() throws Exception { File source = writeSourceFile(testCase); File ownedSource = ownedSourceFile(testCase, source); File stateFile = stateFile(testCase, source); - recordState(testCase, states, stateFile, "pending"); + recordState(testCase, states, stateFile, testCase.initialState); final GeneratedTusManagedUploadUrlStore urlStore = new GeneratedTusManagedUploadUrlStore(); final TusClient client = new TusClient(); @@ -700,7 +711,7 @@ private TusExecutor managedExecutorFor( protected void makeAttempt() throws ProtocolException, IOException { GeneratedTusManagedUploadAttempt attempt = testCase.attempts[attemptIndex]; attemptIndex += 1; - recordState(testCase, states, stateFile, "running"); + recordState(testCase, states, stateFile, attempt.stateBeforeAttempt); try { TusUpload upload = uploadFor(testCase, ownedSource); @@ -795,7 +806,7 @@ private void prepareSourceBeforeProtocol( if (source.exists() && !source.delete()) { throw new IOException("Could not remove generated input source " + source); } - recordState(testCase, states, stateFile, "running"); + recordState(testCase, states, stateFile, attempt.stateBeforeAttempt); try { copyDurableSource(testCase, source, ownedSource); } catch (IOException error) { @@ -1015,6 +1026,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final boolean useDurableOsScheduler; final boolean useFilesystemStateBackend; final boolean usePlatformKeyValueStateBackend; + final String initialState; final String locationHeaderName; final boolean expectDeferredNetworkResult; final boolean expectTerminalFailure; @@ -1053,6 +1065,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; this.usePlatformKeyValueStateBackend = runtimeCapabilities.usePlatformKeyValueStateBackend; + this.initialState = transport.initialState; this.locationHeaderName = transport.locationHeaderName; this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; @@ -1122,9 +1135,11 @@ private static final class GeneratedTusManagedUploadRuntimeCapabilities { } private static final class GeneratedTusManagedUploadTransport { + final String initialState; final String locationHeaderName; - GeneratedTusManagedUploadTransport(String locationHeaderName) { + GeneratedTusManagedUploadTransport(String initialState, String locationHeaderName) { + this.initialState = initialState; this.locationHeaderName = locationHeaderName; } } @@ -1208,16 +1223,19 @@ private static final class GeneratedTusManagedUploadInput { private static final class GeneratedTusManagedUploadAttempt { final int attemptIndex; final String stateAfterAttempt; + final String stateBeforeAttempt; final GeneratedTusManagedUploadFailure failure; final GeneratedTusManagedUploadRequest[] requests; GeneratedTusManagedUploadAttempt( int attemptIndex, + String stateBeforeAttempt, String stateAfterAttempt, GeneratedTusManagedUploadFailure failure, GeneratedTusManagedUploadRequest[] requests) { this.attemptIndex = attemptIndex; this.stateAfterAttempt = stateAfterAttempt; + this.stateBeforeAttempt = stateBeforeAttempt; this.failure = failure; this.requests = requests; } From 112bc477fbdb800c1291917c1f2b16d0696b527f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 22:06:48 +0200 Subject: [PATCH 67/96] Group managed upload runtime fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 330 +++++++++++------- 1 file changed, 202 insertions(+), 128 deletions(-) diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 8889c894..b1457491 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -47,9 +47,19 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { true, false ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", "pending", - "Location" + new String[] { + "pending", + "running", + "failed", + "running", + "succeeded", + }, + new int[] { + 0, + } ), new GeneratedTusManagedUploadOutcomeExpectations( false, @@ -58,33 +68,28 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { true ), new GeneratedTusManagedUploadExecution( - true, - false, - false, - false, - true, - true, - false, - false + new GeneratedTusManagedUploadTerminalExecution( + true, + false, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) ), new GeneratedTusManagedUploadStateExpectations( true, false, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - "running", - "failed", - "running", - "succeeded", - }, - new int[] { - 0, - } - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello managed!", 7, "managed-durable-retry-fingerprint", @@ -95,8 +100,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "managed.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -226,7 +231,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -238,9 +244,15 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { true, false ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", "pending", - "Location" + new String[] { + "pending", + "running", + "failed", + }, + new int[0] ), new GeneratedTusManagedUploadOutcomeExpectations( false, @@ -249,29 +261,28 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { false ), new GeneratedTusManagedUploadExecution( - false, - false, - false, - true, - true, - true, - false, - false + new GeneratedTusManagedUploadTerminalExecution( + false, + false, + true + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) ), new GeneratedTusManagedUploadStateExpectations( true, true, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - "running", - "failed", - }, - new int[0] - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello failure!", 7, "managed-permanent-failure-fingerprint", @@ -282,8 +293,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "managed-permanent-failure.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -321,7 +332,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -333,32 +345,9 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { true, false ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", "pending", - "Location" - ), - new GeneratedTusManagedUploadOutcomeExpectations( - false, - true, - true, - false - ), - new GeneratedTusManagedUploadExecution( - false, - false, - true, - true, - true, - true, - false, - false - ), - new GeneratedTusManagedUploadStateExpectations( - true, - true, - false - ), - new GeneratedTusManagedUploadRetryPlan( new String[] { "pending", "running", @@ -373,7 +362,35 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { 0, } ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false + ), + new GeneratedTusManagedUploadExecution( + new GeneratedTusManagedUploadTerminalExecution( + false, + true, + true + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false + ), + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello retries!", 7, "managed-retry-exhausted-fingerprint", @@ -384,8 +401,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "managed-retry-exhausted.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -497,7 +514,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ), } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -509,9 +527,15 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { true, false ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { "pending", - "Location" + "running", + "failed", + }, + new int[0] ), new GeneratedTusManagedUploadOutcomeExpectations( false, @@ -520,29 +544,28 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { false ), new GeneratedTusManagedUploadExecution( - false, - false, - true, - false, - true, - false, - true, - true + new GeneratedTusManagedUploadTerminalExecution( + false, + true, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + false, + true, + true + ) ), new GeneratedTusManagedUploadStateExpectations( false, false, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - "running", - "failed", - }, - new int[0] - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello missing!", 7, "managed-source-unavailable-fingerprint", @@ -553,8 +576,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "managed-source-unavailable.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -570,7 +593,8 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { } ), - } + } + ) ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = @@ -1052,21 +1076,19 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, - GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadRuntimePlan runtimePlan, GeneratedTusManagedUploadOutcomeExpectations outcomeExpectations, GeneratedTusManagedUploadExecution execution, GeneratedTusManagedUploadStateExpectations stateExpectations, - GeneratedTusManagedUploadRetryPlan retryPlan, - GeneratedTusManagedUploadInput input, - GeneratedTusManagedUploadAttempt[] attempts) { + GeneratedTusManagedUploadWorkload workload) { this.scenarioId = profile.scenarioId; this.copySourceToOwnedStorage = runtimeCapabilities.copySourceToOwnedStorage; this.useDurableOsScheduler = runtimeCapabilities.useDurableOsScheduler; this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; this.usePlatformKeyValueStateBackend = runtimeCapabilities.usePlatformKeyValueStateBackend; - this.initialState = transport.initialState; - this.locationHeaderName = transport.locationHeaderName; + this.initialState = runtimePlan.initialState; + this.locationHeaderName = runtimePlan.locationHeaderName; this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; this.expectTerminalResult = outcomeExpectations.expectTerminalResult; @@ -1082,11 +1104,11 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.expectInputSourceExists = stateExpectations.inputSourceExists; this.expectOwnedSourceExists = stateExpectations.ownedSourceExists; this.expectResumeUrlExists = stateExpectations.resumeUrlExists; - this.expectedStates = retryPlan.expectedStates; - this.retryDelays = retryPlan.retryDelays; + this.expectedStates = runtimePlan.expectedStates; + this.retryDelays = runtimePlan.retryDelays; this.offsetDiscoveryMethod = offsetDiscoveryMethod(); - this.input = input; - this.attempts = attempts; + this.input = workload.input; + this.attempts = workload.attempts; } } @@ -1134,13 +1156,21 @@ private static final class GeneratedTusManagedUploadRuntimeCapabilities { } } - private static final class GeneratedTusManagedUploadTransport { + private static final class GeneratedTusManagedUploadRuntimePlan { + final String[] expectedStates; final String initialState; final String locationHeaderName; + final int[] retryDelays; - GeneratedTusManagedUploadTransport(String initialState, String locationHeaderName) { + GeneratedTusManagedUploadRuntimePlan( + String locationHeaderName, + String initialState, + String[] expectedStates, + int[] retryDelays) { + this.expectedStates = expectedStates; this.initialState = initialState; this.locationHeaderName = locationHeaderName; + this.retryDelays = retryDelays; } } @@ -1155,19 +1185,61 @@ private static final class GeneratedTusManagedUploadExecution { final boolean sourceUnavailableBeforeProtocol; GeneratedTusManagedUploadExecution( + GeneratedTusManagedUploadTerminalExecution terminalExecution, + GeneratedTusManagedUploadSchedulingExecution schedulingExecution, + GeneratedTusManagedUploadSourceExecution sourceExecution) { + this.cleanupOwnedSourceAfterTerminalState = + terminalExecution.cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = schedulingExecution.deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = + terminalExecution.expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = + terminalExecution.expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = schedulingExecution.networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = + sourceExecution.prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = + sourceExecution.simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = sourceExecution.sourceUnavailableBeforeProtocol; + } + } + + private static final class GeneratedTusManagedUploadTerminalExecution { + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + + GeneratedTusManagedUploadTerminalExecution( boolean cleanupOwnedSourceAfterTerminalState, - boolean deferBeforeProtocol, boolean expectIoExceptionOnTerminalFailure, - boolean expectProtocolExceptionOnTerminalFailure, - boolean networkConstraintSatisfied, - boolean prepareDurableSourceBeforeProtocol, - boolean simulateMissingSourceBeforeDurableCopy, - boolean sourceUnavailableBeforeProtocol) { + boolean expectProtocolExceptionOnTerminalFailure) { this.cleanupOwnedSourceAfterTerminalState = cleanupOwnedSourceAfterTerminalState; - this.deferBeforeProtocol = deferBeforeProtocol; this.expectIoExceptionOnTerminalFailure = expectIoExceptionOnTerminalFailure; this.expectProtocolExceptionOnTerminalFailure = expectProtocolExceptionOnTerminalFailure; + } + } + + private static final class GeneratedTusManagedUploadSchedulingExecution { + final boolean deferBeforeProtocol; + final boolean networkConstraintSatisfied; + + GeneratedTusManagedUploadSchedulingExecution( + boolean deferBeforeProtocol, + boolean networkConstraintSatisfied) { + this.deferBeforeProtocol = deferBeforeProtocol; this.networkConstraintSatisfied = networkConstraintSatisfied; + } + } + + private static final class GeneratedTusManagedUploadSourceExecution { + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + + GeneratedTusManagedUploadSourceExecution( + boolean prepareDurableSourceBeforeProtocol, + boolean simulateMissingSourceBeforeDurableCopy, + boolean sourceUnavailableBeforeProtocol) { this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; @@ -1189,16 +1261,6 @@ private static final class GeneratedTusManagedUploadStateExpectations { } } - private static final class GeneratedTusManagedUploadRetryPlan { - final String[] expectedStates; - final int[] retryDelays; - - GeneratedTusManagedUploadRetryPlan(String[] expectedStates, int[] retryDelays) { - this.expectedStates = expectedStates; - this.retryDelays = retryDelays; - } - } - private static final class GeneratedTusManagedUploadInput { final String content; final int chunkSize; @@ -1220,6 +1282,18 @@ private static final class GeneratedTusManagedUploadInput { } } + private static final class GeneratedTusManagedUploadWorkload { + final GeneratedTusManagedUploadAttempt[] attempts; + final GeneratedTusManagedUploadInput input; + + GeneratedTusManagedUploadWorkload( + GeneratedTusManagedUploadInput input, + GeneratedTusManagedUploadAttempt[] attempts) { + this.attempts = attempts; + this.input = input; + } + } + private static final class GeneratedTusManagedUploadAttempt { final int attemptIndex; final String stateAfterAttempt; From d507b37981bc704f8e3476f41e996ea4a4980c10 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 22:37:58 +0200 Subject: [PATCH 68/96] Use generated TUS offset discovery method --- .../java/client/GeneratedTusProtocolContract.java | 14 ++++++++++++++ .../TestGeneratedTusManagedUploadRuntime.java | 9 +-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 30923d32..30c9f5bc 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -361,6 +361,10 @@ final class GeneratedTusProtocolContract { ), }; + static final String OFFSET_DISCOVERY_OPERATION_ID = + "getTusUploadOffset"; + static final String OFFSET_DISCOVERY_METHOD = operationMethod(OFFSET_DISCOVERY_OPERATION_ID); + static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( @@ -1361,6 +1365,16 @@ final class GeneratedTusProtocolContract { private GeneratedTusProtocolContract() { } + private static String operationMethod(String operationId) { + for (GeneratedTusProtocolOperation operation : OPERATIONS) { + if (operationId.equals(operation.operationId)) { + return operation.method; + } + } + + throw new AssertionError("Missing generated operation " + operationId); + } + private static Map defaultRequestHeaders() { Map result = new LinkedHashMap(); result.put("Tus-Resumable", "1.0.0"); diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index b1457491..12120ae0 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -1034,14 +1034,7 @@ private URL uploadUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws E } private static String offsetDiscoveryMethod() { - for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation - : GeneratedTusProtocolContract.OPERATIONS) { - if ("offset-discovery".equals(operation.role)) { - return operation.method; - } - } - - throw new AssertionError("Missing generated offset-discovery operation"); + return GeneratedTusProtocolContract.OFFSET_DISCOVERY_METHOD; } private static final class GeneratedTusManagedUploadRuntimeCase { From 28f3386b19880441ee2c87ba519f99cfd46aeb36 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 10:55:49 +0200 Subject: [PATCH 69/96] Regenerate exact Java TUS transport tests --- .../client/GeneratedTusProtocolContract.java | 2 +- .../TestGeneratedTusManagedUploadRuntime.java | 65 +++---------- .../client/TestGeneratedTusRuntimeEvents.java | 95 ++++++++----------- 3 files changed, 52 insertions(+), 110 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index 30c9f5bc..ea660c98 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -1176,7 +1176,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\",\n \"transportProfileId\": \"java-http-url-connection\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\",\n \"transportProfileId\": \"java-http-url-connection\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 12120ae0..9a980c98 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -143,7 +143,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ) ), new GeneratedTusManagedUploadRequest( - "PATCH", + "POST", "upload", 7, 204, @@ -158,6 +158,10 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusManagedUploadHeader( + "X-HTTP-Method-Override", + "PATCH" + ), } ), new GeneratedTusManagedUploadHeaderSet( @@ -202,7 +206,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ) ), new GeneratedTusManagedUploadRequest( - "PATCH", + "POST", "upload", 7, 204, @@ -217,6 +221,10 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "X-HTTP-Method-Override", + "PATCH" + ), } ), new GeneratedTusManagedUploadHeaderSet( @@ -597,15 +605,6 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { ) ), }; - private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = - new GeneratedTusMethodOverride[] { - new GeneratedTusMethodOverride( - "PATCH", - "POST", - "X-HTTP-Method-Override", - "PATCH" - ), - }; /** * Verifies a durable source can retry, resume, finish, and clean up from contract data. @@ -951,24 +950,17 @@ private File stateFile( private void registerResponses(GeneratedTusManagedUploadRuntimeCase testCase) throws Exception { for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { for (GeneratedTusManagedUploadRequest request : attempt.requests) { - mockServer.when(requestFor(testCase, request, request.method, null)) + mockServer.when(requestFor(testCase, request)) .respond(responseFor(testCase, request)); - GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); - if (methodOverride != null) { - mockServer.when(requestFor(testCase, request, methodOverride.method, methodOverride)) - .respond(responseFor(testCase, request)); - } } } } private HttpRequest requestFor( GeneratedTusManagedUploadRuntimeCase testCase, - GeneratedTusManagedUploadRequest request, - String method, - GeneratedTusMethodOverride methodOverride) throws Exception { + GeneratedTusManagedUploadRequest request) throws Exception { HttpRequest httpRequest = new HttpRequest() - .withMethod(method) + .withMethod(request.method) .withPath(pathFor(testCase, request)); if (request.requestHeaders.includesDefaultProtocolHeaders) { for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { @@ -978,22 +970,9 @@ private HttpRequest requestFor( for (GeneratedTusManagedUploadHeader header : request.requestHeaders.headers) { httpRequest.withHeader(header.name, header.value); } - if (methodOverride != null) { - httpRequest.withHeader(methodOverride.headerName, methodOverride.headerValue); - } return httpRequest; } - private GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { - for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { - if (methodOverride.originalMethod.equals(originalMethod)) { - return methodOverride; - } - } - - return null; - } - private String pathFor( GeneratedTusManagedUploadRuntimeCase testCase, GeneratedTusManagedUploadRequest request) throws Exception { @@ -1385,24 +1364,6 @@ private static final class GeneratedTusManagedUploadMetadata { } } - private static final class GeneratedTusMethodOverride { - final String originalMethod; - final String method; - final String headerName; - final String headerValue; - - GeneratedTusMethodOverride( - String originalMethod, - String method, - String headerName, - String headerValue) { - this.originalMethod = originalMethod; - this.method = method; - this.headerName = headerName; - this.headerValue = headerValue; - } - } - private static final class GeneratedTusManagedUploadUrlStore implements TusURLStore { private final Map values = new LinkedHashMap(); diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 2a50c44f..41b5f1af 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -89,7 +89,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -102,6 +102,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -173,7 +177,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -186,6 +190,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "5" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -258,7 +266,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -271,6 +279,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -343,7 +355,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -360,6 +372,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -456,7 +472,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -469,6 +485,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "0" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -479,7 +499,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -492,6 +512,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "5" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -502,7 +526,7 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), new GeneratedTusRuntimeEventRequest( - "PATCH", + "POST", "upload", 204, true, @@ -519,6 +543,10 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { "Upload-Offset", "10" ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), }, true, new GeneratedTusRuntimeEventHeader[] { @@ -531,15 +559,6 @@ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { } ), }; - private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = - new GeneratedTusMethodOverride[] { - new GeneratedTusMethodOverride( - "PATCH", - "POST", - "X-HTTP-Method-Override", - "PATCH" - ), - }; /** * Verifies the sync uploader emits generated progress and chunk-complete events. @@ -740,23 +759,16 @@ private Map metadataFor(GeneratedTusRuntimeEventMetadata[] metad private void registerResponses(GeneratedTusRuntimeEventCase testCase) throws Exception { for (GeneratedTusRuntimeEventRequest request : testCase.requests) { - mockServer.when(requestFor(testCase, request, request.method, null)) + mockServer.when(requestFor(testCase, request)) .respond(responseFor(testCase, request)); - GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); - if (methodOverride != null) { - mockServer.when(requestFor(testCase, request, methodOverride.method, methodOverride)) - .respond(responseFor(testCase, request)); - } } } private HttpRequest requestFor( GeneratedTusRuntimeEventCase testCase, - GeneratedTusRuntimeEventRequest request, - String method, - GeneratedTusMethodOverride methodOverride) throws Exception { + GeneratedTusRuntimeEventRequest request) throws Exception { HttpRequest httpRequest = new HttpRequest() - .withMethod(method) + .withMethod(request.method) .withPath(pathFor(testCase, request)); if (request.includesDefaultProtocolRequestHeaders) { for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { @@ -766,22 +778,9 @@ private HttpRequest requestFor( for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { httpRequest.withHeader(header.name, header.value); } - if (methodOverride != null) { - httpRequest.withHeader(methodOverride.headerName, methodOverride.headerValue); - } return httpRequest; } - private GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { - for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { - if (methodOverride.originalMethod.equals(originalMethod)) { - return methodOverride; - } - } - - return null; - } - private String pathFor( GeneratedTusRuntimeEventCase testCase, GeneratedTusRuntimeEventRequest request) throws Exception { @@ -1106,24 +1105,6 @@ private static final class GeneratedTusRuntimeEventMetadata { } } - private static final class GeneratedTusMethodOverride { - final String originalMethod; - final String method; - final String headerName; - final String headerValue; - - GeneratedTusMethodOverride( - String originalMethod, - String method, - String headerName, - String headerValue) { - this.originalMethod = originalMethod; - this.method = method; - this.headerName = headerName; - this.headerValue = headerValue; - } - } - private static final class GeneratedTusRuntimeEventUrlStore implements TusURLStore { private final Map values = new LinkedHashMap(); From d98214b564403e13eafdc662c390520e8004336b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:39:01 +0200 Subject: [PATCH 70/96] Regenerate TUS protocol response fixtures --- .../client/GeneratedTusProtocolContract.java | 45 +++++++++++++++++++ .../TestGeneratedTusManagedUploadRuntime.java | 28 ++++++++++-- .../client/TestGeneratedTusRuntimeEvents.java | 28 +++++++++++- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java index ea660c98..1c45dd50 100644 --- a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -181,6 +181,21 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusResponseContract( + 500, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), } ), new GeneratedTusProtocolOperation( @@ -301,6 +316,21 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusResponseContract( + 500, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), } ), new GeneratedTusProtocolOperation( @@ -339,6 +369,21 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusResponseContract( + 423, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), } ), new GeneratedTusProtocolOperation( diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java index 9a980c98..80274448 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -80,6 +80,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -281,6 +282,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -389,6 +391,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -442,7 +445,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { } ), new GeneratedTusManagedUploadHeaderSet( - false, + true, new GeneratedTusManagedUploadHeader[0] ) ), @@ -479,7 +482,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { } ), new GeneratedTusManagedUploadHeaderSet( - false, + true, new GeneratedTusManagedUploadHeader[0] ) ), @@ -516,7 +519,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { } ), new GeneratedTusManagedUploadHeaderSet( - false, + true, new GeneratedTusManagedUploadHeader[0] ) ), @@ -564,6 +567,7 @@ public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { new GeneratedTusManagedUploadSourceExecution( false, true, + 0, true ) ), @@ -825,7 +829,12 @@ private void prepareSourceBeforeProtocol( return; } if (testCase.simulateMissingSourceBeforeDurableCopy) { - GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; + GeneratedTusManagedUploadAttempt attempt = testCase.sourcePreparationFailureAttempt; + if (attempt == null) { + throw new AssertionError( + testCase.scenarioId + + " is missing generated source preparation failure attempt"); + } if (source.exists() && !source.delete()) { throw new IOException("Could not remove generated input source " + source); } @@ -1044,6 +1053,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String offsetDiscoveryMethod; final GeneratedTusManagedUploadInput input; final GeneratedTusManagedUploadAttempt[] attempts; + final GeneratedTusManagedUploadAttempt sourcePreparationFailureAttempt; GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, @@ -1081,6 +1091,10 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.offsetDiscoveryMethod = offsetDiscoveryMethod(); this.input = workload.input; this.attempts = workload.attempts; + this.sourcePreparationFailureAttempt = + execution.sourcePreparationFailureAttemptIndex < 0 + ? null + : workload.attempts[execution.sourcePreparationFailureAttemptIndex]; } } @@ -1154,6 +1168,7 @@ private static final class GeneratedTusManagedUploadExecution { final boolean networkConstraintSatisfied; final boolean prepareDurableSourceBeforeProtocol; final boolean simulateMissingSourceBeforeDurableCopy; + final int sourcePreparationFailureAttemptIndex; final boolean sourceUnavailableBeforeProtocol; GeneratedTusManagedUploadExecution( @@ -1172,6 +1187,8 @@ private static final class GeneratedTusManagedUploadExecution { sourceExecution.prepareDurableSourceBeforeProtocol; this.simulateMissingSourceBeforeDurableCopy = sourceExecution.simulateMissingSourceBeforeDurableCopy; + this.sourcePreparationFailureAttemptIndex = + sourceExecution.sourcePreparationFailureAttemptIndex; this.sourceUnavailableBeforeProtocol = sourceExecution.sourceUnavailableBeforeProtocol; } } @@ -1206,14 +1223,17 @@ private static final class GeneratedTusManagedUploadSchedulingExecution { private static final class GeneratedTusManagedUploadSourceExecution { final boolean prepareDurableSourceBeforeProtocol; final boolean simulateMissingSourceBeforeDurableCopy; + final int sourcePreparationFailureAttemptIndex; final boolean sourceUnavailableBeforeProtocol; GeneratedTusManagedUploadSourceExecution( boolean prepareDurableSourceBeforeProtocol, boolean simulateMissingSourceBeforeDurableCopy, + int sourcePreparationFailureAttemptIndex, boolean sourceUnavailableBeforeProtocol) { this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; + this.sourcePreparationFailureAttemptIndex = sourcePreparationFailureAttemptIndex; this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; } } diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java index 41b5f1af..5df4abba 100644 --- a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -26,6 +26,15 @@ * Tests generated TUS client runtime event fixtures against the real uploader. */ public class TestGeneratedTusRuntimeEvents extends MockServerProvider { + private static final boolean GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK = + true; + private static final boolean GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION = + true; + private static final boolean GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED = + true; + private static final boolean GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION = + true; + private static final GeneratedTusRuntimeEventCase[] CASES = new GeneratedTusRuntimeEventCase[] { new GeneratedTusRuntimeEventCase( @@ -851,7 +860,7 @@ private void assertStoredUploadState( } URL storedUrl = urlStore.get(testCase.input.storedUpload.fingerprint); - if (testCase.input.storedUpload.removeFingerprintOnSuccess) { + if (shouldRemoveStoredUploadOnSuccess(testCase.input.storedUpload)) { assertNull(testCase.scenarioId, storedUrl); return; } @@ -859,6 +868,23 @@ private void assertStoredUploadState( assertEquals(testCase.scenarioId, uploadUrlForUnchecked(testCase), storedUrl); } + private boolean shouldRemoveStoredUploadOnSuccess( + GeneratedTusRuntimeEventStoredUpload storedUpload) { + if (!GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK) { + return false; + } + if (!GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED) { + return false; + } + if ( + GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION + || GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION) { + return storedUpload.removeFingerprintOnSuccess; + } + + return true; + } + private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { try { return uploadUrlFor(testCase); From 12811527b0d574f72e0e5ffb572a1e860752b0f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 05:05:43 +0200 Subject: [PATCH 71/96] Add Java TUS devdock resume coverage --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 152 ++++++++++++++++ .../example/Api2DevdockTusResumeUpload.java | 172 ++++++++++++++++++ .../java/example/Api2DevdockTusUpload.java | 128 +------------ 4 files changed, 337 insertions(+), 121 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockScenario.java create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java diff --git a/example/build.gradle b/example/build.gradle index 558931d9..1373c167 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -11,3 +11,9 @@ tasks.register('api2DevdockTusUpload', JavaExec) { mainClass = 'io.tus.java.example.Api2DevdockTusUpload' workingDir = rootProject.projectDir } + +tasks.register('api2DevdockTusResumeUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusResumeUpload' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java new file mode 100644 index 00000000..5b561f08 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -0,0 +1,152 @@ +package io.tus.java.example; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +final class Api2DevdockScenario { + static JSONObject loadScenario() throws IOException { + String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath == null || scenarioPath.isEmpty()) { + scenarioPath = "example/api2-scenario.json"; + } + + final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + static void writeResult(JSONObject result) throws IOException { + final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) + ); + } + + static JSONObject createResponse(JSONObject scenario) { + return scenario.getJSONObject("prepared").getJSONObject("createResponse"); + } + + static byte[] scenarioBytes(JSONObject uploadConfig) { + final JSONObject source = uploadConfig.getJSONObject("source"); + final String kind = source.getString("kind"); + if (!"bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported source kind " + kind); + } + + final String encoding = source.getString("encoding"); + if (!"utf8".equals(encoding)) { + throw new IllegalArgumentException("unsupported source encoding " + encoding); + } + + return source.getString("value").getBytes(StandardCharsets.UTF_8); + } + + static int fixedChunkSizeBytes(JSONObject uploadConfig) { + final JSONObject chunkSize = uploadConfig.getJSONObject("chunkSize"); + final String kind = chunkSize.getString("kind"); + if (!"fixed-bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported chunk size policy " + kind); + } + + return chunkSize.getInt("bytes"); + } + + static void requireFullFileChunkSize(JSONObject uploadConfig) { + final Object chunkSize = uploadConfig.get("chunkSize"); + if (!"full-file".equals(chunkSize)) { + throw new IllegalArgumentException("unsupported chunk size policy " + chunkSize); + } + } + + static String tusUrl( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) { + return scalarString(resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse)); + } + + static Map uploadMetadata( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) { + final JSONArray fields = uploadConfig.getJSONArray("metadata"); + final Map metadata = new LinkedHashMap(); + for (int index = 0; index < fields.length(); index++) { + final JSONObject field = fields.getJSONObject(index); + metadata.put( + field.getString("name"), + scalarString(resolveValue(field.getJSONObject("value"), scenario, createResponse)) + ); + } + + return metadata; + } + + private static Object resolveValue( + JSONObject valueSpec, + JSONObject scenario, + JSONObject createResponse + ) { + if (valueSpec.has("value")) { + return valueSpec.get("value"); + } + + final JSONObject source = valueSpec.getJSONObject("source"); + final String root = source.getString("root"); + final Object rootValue; + if ("scenario".equals(root)) { + rootValue = scenario; + } else if ("createResponse".equals(root)) { + rootValue = createResponse; + } else { + throw new IllegalArgumentException("unsupported scenario value root " + root); + } + + return readPath(rootValue, source.getJSONArray("path")); + } + + private static Object readPath(Object value, JSONArray pathParts) { + Object current = value; + for (int index = 0; index < pathParts.length(); index++) { + final Object part = pathParts.get(index); + if (current instanceof JSONObject && part instanceof String) { + current = ((JSONObject) current).get((String) part); + continue; + } + + if (current instanceof JSONArray && part instanceof Number) { + current = ((JSONArray) current).get(((Number) part).intValue()); + continue; + } + + throw new IllegalArgumentException("cannot read scenario path part " + part); + } + + return current; + } + + private static String scalarString(Object value) { + if (JSONObject.NULL.equals(value)) { + return "null"; + } + + return String.valueOf(value); + } + + private Api2DevdockScenario() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java new file mode 100644 index 00000000..58318732 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java @@ -0,0 +1,172 @@ +package io.tus.java.example; + +import io.tus.java.client.FingerprintNotFoundException; +import io.tus.java.client.ProtocolException; +import io.tus.java.client.ResumingNotEnabledException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +public final class Api2DevdockTusResumeUpload { + /** + * Run the API2 devdock TUS resume example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithStoredResume(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " resumed " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithStoredResume( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException, FingerprintNotFoundException, + ResumingNotEnabledException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject resume = uploadConfig.getJSONObject("resume"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final String fingerprint = resume.getString("fingerprint"); + + final CountingTusURLStore store = new CountingTusURLStore(); + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(store); + if (resume.getBoolean("removeFingerprintOnSuccess")) { + client.enableRemoveFingerprintOnSuccess(); + } + + final TusUpload firstUpload = uploadFor(scenario, createResponse, content, fingerprint); + final TusUploader firstUploader = client.createUpload(firstUpload); + firstUploader.setChunkSize(chunkSize); + final int firstAcceptedBytes = firstUploader.uploadChunk(); + if (firstAcceptedBytes != resume.getInt("stopAfterAcceptedBytes")) { + throw new IllegalStateException( + "first upload accepted " + + firstAcceptedBytes + + " bytes, expected " + + resume.getInt("stopAfterAcceptedBytes") + ); + } + firstUploader.finish(false); + final String firstUploadUrl = firstUploader.getUploadURL().toString(); + final int previousUploadCount = store.size(); + if (previousUploadCount != resume.getInt("expectedPreviousUploadCount")) { + throw new IllegalStateException( + "stored upload count " + + previousUploadCount + + ", expected " + + resume.getInt("expectedPreviousUploadCount") + ); + } + + final TusUpload secondUpload = uploadFor(scenario, createResponse, content, fingerprint); + final TusUploader resumedUploader = client.resumeUpload(secondUpload); + resumedUploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = resumedUploader.uploadChunk(); + } while (uploadedChunkSize > -1); + resumedUploader.finish(); + + final String uploadUrl = resumedUploader.getUploadURL().toString(); + if (!firstUploadUrl.equals(uploadUrl)) { + throw new IllegalStateException( + "resumed upload URL " + uploadUrl + ", expected " + firstUploadUrl + ); + } + if (resumedUploader.getOffset() != content.length) { + throw new IllegalStateException( + "remote offset " + resumedUploader.getOffset() + ", expected " + content.length + ); + } + + final int remainingPreviousUploadCount = store.size(); + if (remainingPreviousUploadCount != resume.getInt("expectedRemainingPreviousUploadCount")) { + throw new IllegalStateException( + "remaining stored upload count " + + remainingPreviousUploadCount + + ", expected " + + resume.getInt("expectedRemainingPreviousUploadCount") + ); + } + + return new JSONObject() + .put("firstAcceptedBytes", firstAcceptedBytes) + .put("firstUploadUrl", firstUploadUrl) + .put("previousUploadCount", previousUploadCount) + .put("remainingPreviousUploadCount", remainingPreviousUploadCount) + .put("uploadUrl", uploadUrl); + } + + private static TusUpload uploadFor( + JSONObject scenario, + JSONObject createResponse, + byte[] content, + String fingerprint + ) { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(fingerprint); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + return upload; + } + + private static final class CountingTusURLStore implements TusURLStore { + private final Map urls = new HashMap(); + + @Override + public void set(String fingerprint, URL url) { + urls.put(fingerprint, url); + } + + @Override + public URL get(String fingerprint) { + return urls.get(fingerprint); + } + + @Override + public void remove(String fingerprint) { + urls.remove(fingerprint); + } + + int size() { + return urls.size(); + } + } + + private Api2DevdockTusResumeUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java index 33845e69..ef4f94f6 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java @@ -5,17 +5,11 @@ import io.tus.java.client.TusURLMemoryStore; import io.tus.java.client.TusUpload; import io.tus.java.client.TusUploader; -import org.json.JSONArray; import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.LinkedHashMap; -import java.util.Map; public final class Api2DevdockTusUpload { /** @@ -27,10 +21,10 @@ public static void main(String[] args) { try { System.setProperty("http.strictPostRedirect", "true"); - final JSONObject scenario = loadScenario(); - final JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); final String uploadUrl = uploadWithTus(scenario, createResponse); - writeResult(uploadUrl); + Api2DevdockScenario.writeResult(new JSONObject().put("uploadUrl", uploadUrl)); System.out.println( "Java TUS SDK devdock scenario " @@ -44,47 +38,23 @@ public static void main(String[] args) { } } - private static JSONObject loadScenario() throws IOException { - String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); - if (scenarioPath == null || scenarioPath.isEmpty()) { - scenarioPath = "example/api2-scenario.json"; - } - - final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); - return new JSONObject(new String(contents, StandardCharsets.UTF_8)); - } - - private static void writeResult(String uploadUrl) throws IOException { - final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); - if (resultPath == null || resultPath.isEmpty()) { - return; - } - - final JSONObject result = new JSONObject(); - result.put("uploadUrl", uploadUrl); - Files.write( - Paths.get(resultPath), - (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) - ); - } - private static String uploadWithTus( JSONObject scenario, JSONObject createResponse ) throws IOException, ProtocolException { final JSONObject uploadConfig = scenario.getJSONObject("upload"); - final Object endpointValue = resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse); - final byte[] content = scenarioBytes(uploadConfig); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); final TusClient client = new TusClient(); - client.setUploadCreationURL(new URL(scalarString(endpointValue))); + client.setUploadCreationURL(new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse))); client.enableResuming(new TusURLMemoryStore()); final TusUpload upload = new TusUpload(); upload.setInputStream(new ByteArrayInputStream(content)); upload.setSize(content.length); upload.setFingerprint(scenario.getString("scenarioId") + "-java-devdock-example"); - upload.setMetadata(uploadMetadata(uploadConfig, scenario, createResponse)); + upload.setMetadata(Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse)); final TusUploader uploader = client.resumeOrCreateUpload(upload); uploader.setChunkSize(content.length); @@ -106,90 +76,6 @@ private static String uploadWithTus( return uploader.getUploadURL().toString(); } - private static byte[] scenarioBytes(JSONObject uploadConfig) { - final JSONObject source = uploadConfig.getJSONObject("source"); - final String kind = source.getString("kind"); - if (!"bytes".equals(kind)) { - throw new IllegalArgumentException("unsupported source kind " + kind); - } - - final String encoding = source.getString("encoding"); - if (!"utf8".equals(encoding)) { - throw new IllegalArgumentException("unsupported source encoding " + encoding); - } - - return source.getString("value").getBytes(StandardCharsets.UTF_8); - } - - private static Map uploadMetadata( - JSONObject uploadConfig, - JSONObject scenario, - JSONObject createResponse - ) { - final JSONArray fields = uploadConfig.getJSONArray("metadata"); - final Map metadata = new LinkedHashMap(); - for (int index = 0; index < fields.length(); index++) { - final JSONObject field = fields.getJSONObject(index); - metadata.put( - field.getString("name"), - scalarString(resolveValue(field.getJSONObject("value"), scenario, createResponse)) - ); - } - - return metadata; - } - - private static Object resolveValue( - JSONObject valueSpec, - JSONObject scenario, - JSONObject createResponse - ) { - if (valueSpec.has("value")) { - return valueSpec.get("value"); - } - - final JSONObject source = valueSpec.getJSONObject("source"); - final String root = source.getString("root"); - final Object rootValue; - if ("scenario".equals(root)) { - rootValue = scenario; - } else if ("createResponse".equals(root)) { - rootValue = createResponse; - } else { - throw new IllegalArgumentException("unsupported scenario value root " + root); - } - - return readPath(rootValue, source.getJSONArray("path")); - } - - private static Object readPath(Object value, JSONArray pathParts) { - Object current = value; - for (int index = 0; index < pathParts.length(); index++) { - final Object part = pathParts.get(index); - if (current instanceof JSONObject && part instanceof String) { - current = ((JSONObject) current).get((String) part); - continue; - } - - if (current instanceof JSONArray && part instanceof Number) { - current = ((JSONArray) current).get(((Number) part).intValue()); - continue; - } - - throw new IllegalArgumentException("cannot read scenario path part " + part); - } - - return current; - } - - private static String scalarString(Object value) { - if (JSONObject.NULL.equals(value)) { - return "null"; - } - - return String.valueOf(value); - } - private Api2DevdockTusUpload() { throw new IllegalStateException("Utility class"); } From 0bce3c605ff5b42a70ff4f3b81b518bd1322aaec Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 05:39:35 +0200 Subject: [PATCH 72/96] Add Java request lifecycle hooks and retry proof --- example/build.gradle | 6 + .../Api2DevdockTusRetryOffsetRecovery.java | 234 ++++++++++++++++++ .../java/io/tus/java/client/TusClient.java | 44 ++++ .../java/client/TusRequestLifecycleHooks.java | 60 +++++ .../java/io/tus/java/client/TusUploader.java | 63 ++--- .../io/tus/java/client/TestTusClient.java | 107 ++++++++ .../io/tus/java/client/TestTusUploader.java | 68 ++++- 7 files changed, 553 insertions(+), 29 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java create mode 100644 src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java diff --git a/example/build.gradle b/example/build.gradle index 1373c167..1525511d 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -17,3 +17,9 @@ tasks.register('api2DevdockTusResumeUpload', JavaExec) { mainClass = 'io.tus.java.example.Api2DevdockTusResumeUpload' workingDir = rootProject.projectDir } + +tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRetryOffsetRecovery' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java new file mode 100644 index 00000000..9b0d4a81 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java @@ -0,0 +1,234 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusExecutor; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusRetryOffsetRecovery { + /** + * Run the API2 devdock TUS retry-offset recovery example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithRetryOffsetRecovery(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " recovered offset for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRetryOffsetRecovery( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject retry = uploadConfig.getJSONObject("retryOffsetRecovery"); + final JSONObject failAfterResponse = retry.getJSONObject("failAfterResponse"); + final JSONObject recoveryResponse = retry.getJSONObject("recoveryResponse"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final List recoveredOffsets = new ArrayList(); + final List requestMethods = new ArrayList(); + final int[] failureCandidateCount = new int[]{0}; + final int[] simulatedFailureCount = new int[]{0}; + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + requestMethods.add(context.getMethod()); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) throws IOException { + if (context.getMethod().equals(recoveryResponse.getString("method"))) { + recoveredOffsets.add(readHeaderInt( + context.getConnection(), + recoveryResponse.getString("offsetHeader") + )); + } + + if (!context.getMethod().equals(failAfterResponse.getString("method"))) { + return; + } + + failureCandidateCount[0] += 1; + if (failureCandidateCount[0] != failAfterResponse.getInt("occurrence")) { + return; + } + + simulatedFailureCount[0] += 1; + throw new IOException(failAfterResponse.getString("message")); + } + } + )); + + final TusUpload upload = uploadFor(scenario, createResponse, content); + final String[] uploadUrl = new String[]{null}; + final long[] finalOffset = new long[]{0}; + final TusExecutor executor = new TusExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(chunkSize); + uploader.setRequestPayloadSize(chunkSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadUrl[0] = uploader.getUploadURL().toString(); + finalOffset[0] = uploader.getOffset(); + } + }; + executor.setDelays(new int[uploadConfig.getInt("retries")]); + if (!executor.makeAttempts()) { + throw new IOException("retry offset recovery was interrupted"); + } + + if (uploadUrl[0] == null) { + throw new IllegalStateException("retry offset recovery TUS upload did not expose a URL"); + } + if (finalOffset[0] != content.length) { + throw new IllegalStateException( + "retry offset recovery upload offset " + + finalOffset[0] + + ", expected " + + content.length + ); + } + if (simulatedFailureCount[0] != retry.getInt("expectedFailureCount")) { + throw new IllegalStateException( + "retry offset recovery expected " + + retry.getInt("expectedFailureCount") + + " simulated failure(s), got " + + simulatedFailureCount[0] + ); + } + if (recoveredOffsets.size() != retry.getInt("expectedRecoveryRequestCount")) { + throw new IllegalStateException( + "retry offset recovery expected " + + retry.getInt("expectedRecoveryRequestCount") + + " recovery request(s), got " + + recoveredOffsets.size() + ); + } + if (recoveredOffsets.get(0) != retry.getInt("expectedRecoveredOffset")) { + throw new IllegalStateException( + "retry offset recovery expected recovered offset " + + retry.getInt("expectedRecoveredOffset") + + ", got " + + recoveredOffsets.get(0) + ); + } + assertRequestMethods(requestMethods, retry.getJSONArray("expectedRequestMethods")); + + return new JSONObject() + .put("recoveredOffsets", new JSONArray(recoveredOffsets)) + .put("recoveryRequestCount", recoveredOffsets.size()) + .put("requestMethods", new JSONArray(requestMethods)) + .put("simulatedFailureCount", simulatedFailureCount[0]) + .put("uploadUrl", uploadUrl[0]); + } + + private static TusUpload uploadFor( + JSONObject scenario, + JSONObject createResponse, + byte[] content + ) { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-retry-offset-recovery"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + return upload; + } + + private static int readHeaderInt(HttpURLConnection connection, String headerName) { + final String value = connection.getHeaderField(headerName); + final int offset; + try { + offset = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalStateException( + "retry offset recovery expected numeric " + + headerName + + " response header, got " + + value + ); + } + if (offset < 0) { + throw new IllegalStateException( + "retry offset recovery expected non-negative offset, got " + offset + ); + } + + return offset; + } + + private static void assertRequestMethods(List actual, JSONArray expected) { + if (actual.size() != expected.length()) { + throw new IllegalStateException( + "retry offset recovery expected request methods " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.length(); index++) { + if (!actual.get(index).equals(expected.getString(index))) { + throw new IllegalStateException( + "retry offset recovery expected request method " + + expected.getString(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private Api2DevdockTusRetryOffsetRecovery() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 8f7eab19..fa2584f6 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -26,6 +26,7 @@ public class TusClient { private TusURLStore urlStore; private Map headers; private int connectTimeout = 5000; + private TusRequestLifecycleHooks requestLifecycleHooks; /** * Create a new tus client. @@ -164,6 +165,25 @@ public Map getHeaders() { return headers; } + /** + * Set request lifecycle callbacks for every HTTP request/response pair. + * + * @param requestLifecycleHooks Hooks to invoke, or null to disable hooks. + */ + public void setRequestLifecycleHooks(@Nullable TusRequestLifecycleHooks requestLifecycleHooks) { + this.requestLifecycleHooks = requestLifecycleHooks; + } + + /** + * Get the configured request lifecycle callbacks. + * + * @return The configured lifecycle hooks or null. + */ + @Nullable + public TusRequestLifecycleHooks getRequestLifecycleHooks() { + return requestLifecycleHooks; + } + /** * Sets the timeout for a Connection. * @param timeout in milliseconds @@ -208,9 +228,11 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept } else { connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize())); } + runBeforeRequest("POST", connection); connection.connect(); int responseCode = connection.getResponseCode(); + runAfterResponse("POST", connection); if (!(responseCode >= 200 && responseCode < 300)) { throw new ProtocolException( "unexpected status code (" + responseCode + ") while creating upload", connection); @@ -303,9 +325,11 @@ public TusUploader beginOrResumeUploadFromURL(@NotNull TusUpload upload, @NotNul connection.setRequestMethod("HEAD"); prepareConnection(connection); + runBeforeRequest("HEAD", connection); connection.connect(); int responseCode = connection.getResponseCode(); + runAfterResponse("HEAD", connection); if (!(responseCode >= 200 && responseCode < 300)) { throw new ProtocolException( "unexpected status code (" + responseCode + ") while resuming upload", connection); @@ -378,6 +402,26 @@ public void prepareConnection(@NotNull HttpURLConnection connection) { } } + void runBeforeRequest(@NotNull String method, @NotNull HttpURLConnection connection) throws IOException { + if (requestLifecycleHooks == null || requestLifecycleHooks.getBeforeRequest() == null) { + return; + } + + requestLifecycleHooks.getBeforeRequest().beforeRequest( + new TusRequestLifecycleHooks.RequestContext(method, connection) + ); + } + + void runAfterResponse(@NotNull String method, @NotNull HttpURLConnection connection) throws IOException { + if (requestLifecycleHooks == null || requestLifecycleHooks.getAfterResponse() == null) { + return; + } + + requestLifecycleHooks.getAfterResponse().afterResponse( + new TusRequestLifecycleHooks.RequestContext(method, connection) + ); + } + /** * Actions to be performed after a successful upload completion. * Manages URL removal from the URL store if remove fingerprint on success is enabled diff --git a/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java b/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java new file mode 100644 index 00000000..0fdff0cd --- /dev/null +++ b/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java @@ -0,0 +1,60 @@ +package io.tus.java.client; + +import java.io.IOException; +import java.net.HttpURLConnection; + +/** + * Callbacks invoked around each HTTP request/response pair. + */ +public final class TusRequestLifecycleHooks { + /** + * Request context passed to lifecycle hooks. + */ + public static final class RequestContext { + private final String method; + private final HttpURLConnection connection; + + RequestContext(String method, HttpURLConnection connection) { + this.method = method; + this.connection = connection; + } + + public String getMethod() { + return method; + } + + public HttpURLConnection getConnection() { + return connection; + } + } + + /** + * Callback invoked before transport sends the request. + */ + public interface BeforeRequest { + void beforeRequest(RequestContext context) throws IOException; + } + + /** + * Callback invoked after transport receives the response. + */ + public interface AfterResponse { + void afterResponse(RequestContext context) throws IOException; + } + + private final BeforeRequest beforeRequest; + private final AfterResponse afterResponse; + + public TusRequestLifecycleHooks(BeforeRequest beforeRequest, AfterResponse afterResponse) { + this.beforeRequest = beforeRequest; + this.afterResponse = afterResponse; + } + + BeforeRequest getBeforeRequest() { + return beforeRequest; + } + + AfterResponse getAfterResponse() { + return afterResponse; + } +} diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index a9f4b232..d8ce5202 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -126,6 +126,7 @@ private void openConnection() throws IOException, ProtocolException { connection.setDoOutput(true); connection.setChunkedStreamingMode(0); + client.runBeforeRequest("PATCH", connection); try { output = connection.getOutputStream(); } catch (java.net.ProtocolException pe) { @@ -411,35 +412,41 @@ private void finishConnection() throws ProtocolException, IOException { } if (connection != null) { - int responseCode = connection.getResponseCode(); - connection.disconnect(); - - if (!(responseCode >= 200 && responseCode < 300)) { - throw new ProtocolException("unexpected status code (" + responseCode + ") while uploading chunk", - connection); - } - - // TODO detect changes and seek accordingly - long serverOffset = getHeaderFieldLong(connection, "Upload-Offset"); - if (serverOffset == -1) { - throw new ProtocolException("response to PATCH request contains no or invalid Upload-Offset header", - connection); - } - if (offset != serverOffset) { - throw new ProtocolException( - String.format("response contains different Upload-Offset value (%d) than expected (%d)", - serverOffset, - offset), - connection); - } - - if (requestDeclaresUploadLength) { - uploadLengthDeclared = true; + HttpURLConnection currentConnection = connection; + try { + int responseCode = currentConnection.getResponseCode(); + client.runAfterResponse("PATCH", currentConnection); + + if (!(responseCode >= 200 && responseCode < 300)) { + throw new ProtocolException("unexpected status code (" + responseCode + ") while uploading chunk", + currentConnection); + } + + // TODO detect changes and seek accordingly + long serverOffset = getHeaderFieldLong(currentConnection, "Upload-Offset"); + if (serverOffset == -1) { + throw new ProtocolException("response to PATCH request contains no or invalid Upload-Offset header", + currentConnection); + } + if (offset != serverOffset) { + throw new ProtocolException( + String.format("response contains different Upload-Offset value (%d) than expected (%d)", + serverOffset, + offset), + currentConnection); + } + + if (requestDeclaresUploadLength) { + uploadLengthDeclared = true; + } + notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); + } finally { + currentConnection.disconnect(); + connection = null; + output = null; + requestDeclaresUploadLength = false; + requestProgressStarted = false; } - notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); - connection = null; - requestDeclaresUploadLength = false; - requestProgressStarted = false; } } diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index e0a723ce..e5f57495 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -8,8 +8,10 @@ import java.net.Proxy; import java.net.Proxy.Type; import java.net.URL; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.junit.Test; @@ -106,6 +108,55 @@ public void testCreateUpload() throws IOException, ProtocolException { assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); } + /** + * Verifies if request lifecycle hooks run around upload creation. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreateUploadRequestLifecycleHooks() throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("X-Hook", "before") + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/foo"))); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + events.add("before:" + context.getMethod()); + context.getConnection().addRequestProperty("X-Hook", "before"); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse(TusRequestLifecycleHooks.RequestContext context) throws IOException { + events.add( + "after:" + + context.getMethod() + + ":" + + context.getConnection().getResponseCode() + ); + } + } + )); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + TusUploader uploader = client.createUpload(upload); + + assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); + assertEquals("before:POST", events.get(0)); + assertEquals("after:POST:201", events.get(1)); + } + /** * Verifies if uploads can be created with deferred upload length. * @throws IOException if upload data cannot be read. @@ -267,6 +318,62 @@ public void testResumeUpload() throws ResumingNotEnabledException, FingerprintNo assertEquals(uploader.getOffset(), 3); } + /** + * Verifies if request lifecycle hooks run around offset discovery. + * @throws ResumingNotEnabledException if resuming is disabled. + * @throws FingerprintNotFoundException if the stored URL is missing. + * @throws IOException if the request cannot be issued. + * @throws ProtocolException if the upload cannot be resumed. + */ + @Test + public void testResumeUploadRequestLifecycleHooks() throws ResumingNotEnabledException, + FingerprintNotFoundException, IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("HEAD") + .withPath("/files/foo") + .withHeader("X-Hook", "before"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .withHeader("Upload-Offset", "3"))); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + client.enableResuming(new TestResumeUploadStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + events.add("before:" + context.getMethod()); + context.getConnection().addRequestProperty("X-Hook", "before"); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse(TusRequestLifecycleHooks.RequestContext context) throws IOException { + events.add( + "after:" + + context.getMethod() + + ":" + + context.getConnection().getResponseCode() + ); + } + } + )); + + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + upload.setFingerprint("test-fingerprint"); + + TusUploader uploader = client.resumeUpload(upload); + + assertEquals(uploader.getUploadURL(), new URL(mockServerURL.toString() + "/foo")); + assertEquals(uploader.getOffset(), 3); + assertEquals("before:HEAD", events.get(0)); + assertEquals("after:HEAD:204", events.get(1)); + } + /** * Test Implementation for a {@link TusURLStore}. */ diff --git a/src/test/java/io/tus/java/client/TestTusUploader.java b/src/test/java/io/tus/java/client/TestTusUploader.java index 9871c0ce..66b41c1b 100644 --- a/src/test/java/io/tus/java/client/TestTusUploader.java +++ b/src/test/java/io/tus/java/client/TestTusUploader.java @@ -2,9 +2,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.io.BufferedReader; import java.io.ByteArrayInputStream; @@ -18,7 +19,9 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import org.junit.Assume; import org.junit.Test; @@ -72,6 +75,69 @@ public void testTusUploader() throws IOException, ProtocolException { uploader.finish(); } + /** + * Verifies if request lifecycle hooks run around PATCH uploads. + * @throws IOException if the upload cannot be read or sent. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testTusUploaderRequestLifecycleHooks() throws IOException, ProtocolException { + byte[] content = "hello".getBytes(); + + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/hooks") + .withHeader("X-Hook", "before") + .withHeader("Upload-Offset", "0") + .withHeader("Content-Type", "application/offset+octet-stream") + .withBody(content))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .withHeader("Upload-Offset", "5"))); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + events.add("before:" + context.getMethod()); + context.getConnection().addRequestProperty("X-Hook", "before"); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse(TusRequestLifecycleHooks.RequestContext context) throws IOException { + events.add( + "after:" + + context.getMethod() + + ":" + + context.getConnection().getResponseCode() + ); + throw new IOException("hook failure"); + } + } + )); + URL uploadUrl = new URL(mockServerURL + "/hooks"); + TusInputStream input = new TusInputStream(new ByteArrayInputStream(content)); + TusUpload upload = new TusUpload(); + upload.setSize(content.length); + + TusUploader uploader = new TusUploader(client, upload, uploadUrl, input, 0); + uploader.setChunkSize(content.length); + uploader.setRequestPayloadSize(content.length); + + try { + uploader.uploadChunk(); + fail("expected hook failure"); + } catch (IOException e) { + assertEquals("hook failure", e.getMessage()); + } + + assertEquals(5, uploader.getOffset()); + assertEquals("before:PATCH", events.get(0)); + assertEquals("after:PATCH:204", events.get(1)); + } + /** * Tests if deferred-length uploads declare the upload length on the first PATCH request. * @throws IOException From aa20322a890735485d112977fe5247304c92c38c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 05:56:01 +0200 Subject: [PATCH 73/96] Document Java request lifecycle hooks --- .../java/io/tus/java/client/TusClient.java | 8 ++++-- .../java/client/TusRequestLifecycleHooks.java | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index fa2584f6..9a911c23 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -402,7 +402,9 @@ public void prepareConnection(@NotNull HttpURLConnection connection) { } } - void runBeforeRequest(@NotNull String method, @NotNull HttpURLConnection connection) throws IOException { + final void runBeforeRequest( + @NotNull String method, @NotNull HttpURLConnection connection + ) throws IOException { if (requestLifecycleHooks == null || requestLifecycleHooks.getBeforeRequest() == null) { return; } @@ -412,7 +414,9 @@ void runBeforeRequest(@NotNull String method, @NotNull HttpURLConnection connect ); } - void runAfterResponse(@NotNull String method, @NotNull HttpURLConnection connection) throws IOException { + final void runAfterResponse( + @NotNull String method, @NotNull HttpURLConnection connection + ) throws IOException { if (requestLifecycleHooks == null || requestLifecycleHooks.getAfterResponse() == null) { return; } diff --git a/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java b/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java index 0fdff0cd..52c4b862 100644 --- a/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java +++ b/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java @@ -19,10 +19,20 @@ public static final class RequestContext { this.connection = connection; } + /** + * Get the logical TUS request method for this request. + * + * @return The request method. + */ public String getMethod() { return method; } + /** + * Get the HTTP connection for this request. + * + * @return The mutable HTTP connection. + */ public HttpURLConnection getConnection() { return connection; } @@ -32,6 +42,12 @@ public HttpURLConnection getConnection() { * Callback invoked before transport sends the request. */ public interface BeforeRequest { + /** + * Handle a request before it is sent. + * + * @param context The request context. + * @throws IOException when the request should fail. + */ void beforeRequest(RequestContext context) throws IOException; } @@ -39,12 +55,24 @@ public interface BeforeRequest { * Callback invoked after transport receives the response. */ public interface AfterResponse { + /** + * Handle a response after it has been received. + * + * @param context The request context. + * @throws IOException when the response should fail. + */ void afterResponse(RequestContext context) throws IOException; } private final BeforeRequest beforeRequest; private final AfterResponse afterResponse; + /** + * Create request lifecycle hooks. + * + * @param beforeRequest Callback invoked before a request is sent. + * @param afterResponse Callback invoked after a response is received. + */ public TusRequestLifecycleHooks(BeforeRequest beforeRequest, AfterResponse afterResponse) { this.beforeRequest = beforeRequest; this.afterResponse = afterResponse; From 101ee03b4e6ec8813725a9390252b4f3741bb5b0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 07:08:10 +0200 Subject: [PATCH 74/96] Add TUS request lifecycle devdock proof --- example/build.gradle | 6 + .../Api2DevdockTusRequestLifecycleHooks.java | 193 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java diff --git a/example/build.gradle b/example/build.gradle index 1525511d..33a3040f 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -23,3 +23,9 @@ tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) { mainClass = 'io.tus.java.example.Api2DevdockTusRetryOffsetRecovery' workingDir = rootProject.projectDir } + +tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java new file mode 100644 index 00000000..f13ed75e --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java @@ -0,0 +1,193 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusRequestLifecycleHooks { + /** + * Run the API2 devdock TUS request lifecycle hooks example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithRequestLifecycleHooks(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed lifecycle hooks for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRequestLifecycleHooks( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject hooksConfig = uploadConfig.getJSONObject("requestLifecycleHooks"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final List beforeRequestMethods = new ArrayList(); + final List afterResponseMethods = new ArrayList(); + final List afterResponseStatusCodes = new ArrayList(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + beforeRequestMethods.add(context.getMethod()); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) throws IOException { + afterResponseMethods.add(context.getMethod()); + afterResponseStatusCodes.add(context.getConnection().getResponseCode()); + } + } + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-request-hooks"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "request lifecycle hooks upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("request lifecycle hooks upload did not return a URL"); + } + assertStringArray( + beforeRequestMethods, + hooksConfig.getJSONArray("expectedBeforeRequestMethods"), + "before request methods" + ); + assertStringArray( + afterResponseMethods, + hooksConfig.getJSONArray("expectedAfterResponseMethods"), + "after response methods" + ); + assertIntegerArray( + afterResponseStatusCodes, + hooksConfig.getJSONArray("expectedAfterResponseStatusCodes"), + "after response status codes" + ); + + return new JSONObject() + .put("afterResponseMethods", new JSONArray(afterResponseMethods)) + .put("afterResponseStatusCodes", new JSONArray(afterResponseStatusCodes)) + .put("beforeRequestMethods", new JSONArray(beforeRequestMethods)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static void assertStringArray(List actual, JSONArray expected, String label) { + if (actual.size() != expected.length()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.length(); index++) { + if (!actual.get(index).equals(expected.getString(index))) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected.getString(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private static void assertIntegerArray( + List actual, + JSONArray expected, + String label + ) { + if (actual.size() != expected.length()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.length(); index++) { + if (actual.get(index) != expected.getInt(index)) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected.getInt(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private Api2DevdockTusRequestLifecycleHooks() { + throw new IllegalStateException("Utility class"); + } +} From 98141b73360e40aa3f2e5cee31107bdb2d7f009e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 08:42:57 +0200 Subject: [PATCH 75/96] Add API2 upload callback proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 164 ++++++++++++++++++ .../Api2DevdockTusUploadCallbacks.java | 154 ++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java diff --git a/example/build.gradle b/example/build.gradle index 33a3040f..1a7b8b83 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -29,3 +29,9 @@ tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' workingDir = rootProject.projectDir } + +tasks.register('api2DevdockTusUploadCallbacks', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusUploadCallbacks' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index 5b561f08..18896d1d 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -7,10 +7,50 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; final class Api2DevdockScenario { + static final class UploadCallbackEventKinds { + final String chunkComplete; + final String progress; + final String sourceClose; + final String success; + final String uploadUrlAvailable; + + UploadCallbackEventKinds(JSONObject eventKinds) { + chunkComplete = eventKinds.getString("chunkComplete"); + progress = eventKinds.getString("progress"); + sourceClose = eventKinds.getString("sourceClose"); + success = eventKinds.getString("success"); + uploadUrlAvailable = eventKinds.getString("uploadUrlAvailable"); + } + } + + static final class UploadCallbacksPlan { + final List allowedExtraEventKeyPrefixes; + final List> eventKeyAlternativeGroups; + final UploadCallbackEventKinds eventKinds; + final String eventKeyPartSeparator; + final List eventKeys; + final String eventPolicyMatching; + + UploadCallbacksPlan(JSONObject uploadCallbacks) { + allowedExtraEventKeyPrefixes = stringList( + uploadCallbacks.getJSONArray("allowedExtraEventKeyPrefixes") + ); + eventKeyAlternativeGroups = stringListList( + uploadCallbacks.getJSONArray("eventKeyAlternativeGroups") + ); + eventKinds = new UploadCallbackEventKinds(uploadCallbacks.getJSONObject("eventKinds")); + eventKeyPartSeparator = uploadCallbacks.getString("eventKeyPartSeparator"); + eventKeys = stringList(uploadCallbacks.getJSONArray("eventKeys")); + eventPolicyMatching = uploadCallbacks.getString("eventPolicyMatching"); + } + } + static JSONObject loadScenario() throws IOException { String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); if (scenarioPath == null || scenarioPath.isEmpty()) { @@ -95,6 +135,80 @@ static Map uploadMetadata( return metadata; } + static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) { + return new UploadCallbacksPlan( + scenario.getJSONObject("upload").getJSONObject("uploadCallbacks") + ); + } + + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { + final StringBuilder key = new StringBuilder(); + for (int index = 0; index < parts.length; index++) { + if (index > 0) { + key.append(plan.eventKeyPartSeparator); + } + key.append(parts[index]); + } + + return key.toString(); + } + + static String uploadCallbackEventKeyNumber(long value) { + return Long.toString(value); + } + + static List matchUploadCallbackEventKeys( + UploadCallbacksPlan plan, + List actual + ) { + if (!"exact".equals(plan.eventPolicyMatching) + && !"exact-except-allowed-extra-events".equals(plan.eventPolicyMatching)) { + throw new IllegalArgumentException( + "unsupported upload callback event policy " + plan.eventPolicyMatching + ); + } + + final List matched = new ArrayList(); + int expectedIndex = 0; + for (String event : actual) { + if (expectedIndex < plan.eventKeys.size() + && uploadCallbackEventMatchesExpected(plan, expectedIndex, event)) { + matched.add(plan.eventKeys.get(expectedIndex)); + expectedIndex += 1; + continue; + } + + if ("exact-except-allowed-extra-events".equals(plan.eventPolicyMatching) + && hasAllowedUploadCallbackExtraEventPrefix(plan, event)) { + continue; + } + + throw new IllegalStateException( + "unexpected upload callback event " + + event + + " at expected index " + + expectedIndex + + "; expected " + + plan.eventKeys + + ", actual " + + actual + ); + } + + if (expectedIndex != plan.eventKeys.size()) { + throw new IllegalStateException( + "missing upload callback events after index " + + expectedIndex + + "; expected " + + plan.eventKeys + + ", actual " + + actual + ); + } + + return matched; + } + private static Object resolveValue( JSONObject valueSpec, JSONObject scenario, @@ -146,6 +260,56 @@ private static String scalarString(Object value) { return String.valueOf(value); } + private static List stringList(JSONArray values) { + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + result.add(values.getString(index)); + } + + return result; + } + + private static List> stringListList(JSONArray values) { + final List> result = new ArrayList>(); + for (int index = 0; index < values.length(); index++) { + result.add(stringList(values.getJSONArray(index))); + } + + return result; + } + + private static boolean uploadCallbackEventMatchesExpected( + UploadCallbacksPlan plan, + int expectedIndex, + String event + ) { + if (plan.eventKeys.get(expectedIndex).equals(event)) { + return true; + } + + final List alternatives = plan.eventKeyAlternativeGroups.get(expectedIndex); + for (String alternative : alternatives) { + if (alternative.equals(event)) { + return true; + } + } + + return false; + } + + private static boolean hasAllowedUploadCallbackExtraEventPrefix( + UploadCallbacksPlan plan, + String event + ) { + for (String prefix : plan.allowedExtraEventKeyPrefixes) { + if (event.startsWith(prefix)) { + return true; + } + } + + return false; + } + private Api2DevdockScenario() { throw new IllegalStateException("Utility class"); } diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java new file mode 100644 index 00000000..9f41da9f --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java @@ -0,0 +1,154 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusUploadCallbacks { + /** + * Run the API2 devdock TUS upload callback example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithCallbacks(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed upload callbacks for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithCallbacks( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Api2DevdockScenario.UploadCallbacksPlan callbacks = + Api2DevdockScenario.uploadCallbacks(scenario); + final List events = new ArrayList(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new EventRecordingByteArrayInputStream(content, callbacks, events)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-upload-callbacks"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.uploadUrlAvailable + )); + uploader.setChunkSize(content.length); + uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.progress, + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesSent), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesTotal) + )); + } + }); + uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.chunkComplete, + Api2DevdockScenario.uploadCallbackEventKeyNumber(chunkSize), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesAccepted), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesTotal) + )); + } + }); + + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "upload callbacks offset " + uploader.getOffset() + ", expected " + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("upload callbacks TUS upload did not expose a URL"); + } + + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.success + )); + uploader.finish(); + + final List matchedEvents = + Api2DevdockScenario.matchUploadCallbackEventKeys(callbacks, events); + return new JSONObject() + .put("eventKeys", new JSONArray(matchedEvents)) + .put("rawEventKeys", new JSONArray(events)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static final class EventRecordingByteArrayInputStream extends ByteArrayInputStream { + private final Api2DevdockScenario.UploadCallbacksPlan callbacks; + private final List events; + + EventRecordingByteArrayInputStream( + byte[] content, + Api2DevdockScenario.UploadCallbacksPlan callbacks, + List events + ) { + super(content); + this.callbacks = callbacks; + this.events = events; + } + + @Override + public void close() throws IOException { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.sourceClose + )); + super.close(); + } + } + + private Api2DevdockTusUploadCallbacks() { + throw new IllegalStateException("Utility class"); + } +} From fc89a620c07f0daeaebaf06df7b9e029f71a454d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 08:46:43 +0200 Subject: [PATCH 76/96] Align callback proof event order --- .../java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java | 1 + 1 file changed, 1 insertion(+) diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java index 9f41da9f..95a65a70 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java @@ -110,6 +110,7 @@ public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) throw new IllegalStateException("upload callbacks TUS upload did not expose a URL"); } + uploader.finish(false); events.add(Api2DevdockScenario.uploadCallbackEventKey( callbacks, callbacks.eventKinds.success From 438327169af9297176cc11297e49e2e0ba70526d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 10:40:38 +0200 Subject: [PATCH 77/96] Allow Android composite Java build --- build.gradle | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 5a6f44d4..9c0a8ef4 100644 --- a/build.gradle +++ b/build.gradle @@ -14,10 +14,12 @@ allprojects { } } -// We compile the library using Java 1.7 compatibility +// We compile the library using Java 8 compatibility // in order to ensure interoperability with older Android platforms. -sourceCompatibility = 1.8 -targetCompatibility = 1.8 +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} // load version number from file def config = new ConfigSlurper().parse(new File("${projectDir}/src/main/resources/tus-java-client-version/version.properties").toURI().toURL()) From 64923dfdb475ed2a0ddd6192733f24da6320b202 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 11:25:30 +0200 Subject: [PATCH 78/96] Add API2 custom request headers proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 13 ++ .../Api2DevdockTusCustomRequestHeaders.java | 132 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java diff --git a/example/build.gradle b/example/build.gradle index 1a7b8b83..f1748bc5 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -30,6 +30,12 @@ tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusCustomRequestHeaders', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusCustomRequestHeaders' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusUploadCallbacks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusUploadCallbacks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index 18896d1d..d311f2c5 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -135,6 +135,10 @@ static Map uploadMetadata( return metadata; } + static Map uploadHeaders(JSONObject uploadConfig) { + return stringMap(uploadConfig.getJSONObject("headers")); + } + static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) { return new UploadCallbacksPlan( scenario.getJSONObject("upload").getJSONObject("uploadCallbacks") @@ -269,6 +273,15 @@ private static List stringList(JSONArray values) { return result; } + private static Map stringMap(JSONObject values) { + final Map result = new LinkedHashMap(); + for (String key : values.keySet()) { + result.put(key, values.getString(key)); + } + + return result; + } + private static List> stringListList(JSONArray values) { final List> result = new ArrayList>(); for (int index = 0; index < values.length(); index++) { diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java new file mode 100644 index 00000000..6b385cdd --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java @@ -0,0 +1,132 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusCustomRequestHeaders { + /** + * Run the API2 devdock TUS custom request headers example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithCustomHeaders(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed custom request headers for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithCustomHeaders( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Map expectedHeaders = Api2DevdockScenario.uploadHeaders(uploadConfig); + final Map> headersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setHeaders(expectedHeaders); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if ("POST".equals(context.getMethod()) || "PATCH".equals(context.getMethod())) { + headersByMethod.put( + context.getMethod(), + observedCustomHeaders(context.getConnection(), expectedHeaders) + ); + } + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-custom-request-headers"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "custom request headers upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("custom request headers upload did not expose a URL"); + } + + return new JSONObject() + .put("headersByMethod", new JSONObject(headersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static Map observedCustomHeaders( + HttpURLConnection connection, + Map expectedHeaders + ) { + final Map headers = new LinkedHashMap(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + final String value = connection.getRequestProperty(entry.getKey()); + if (value == null) { + throw new IllegalStateException( + "custom request headers did not observe " + entry.getKey() + ); + } + + headers.put(entry.getKey(), value); + } + + return headers; + } + + private Api2DevdockTusCustomRequestHeaders() { + throw new IllegalStateException("Utility class"); + } +} From ca49fb1c784bd539839d1cb9596b811c705360c3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 12:33:33 +0200 Subject: [PATCH 79/96] Add API2 request ID headers proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 8 + .../Api2DevdockTusRequestIdHeaders.java | 141 ++++++++++++++++++ .../java/io/tus/java/client/TusClient.java | 34 +++-- .../java/io/tus/java/client/TusProtocol.java | 40 +++++ 5 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java diff --git a/example/build.gradle b/example/build.gradle index f1748bc5..a68e950d 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -36,6 +36,12 @@ tasks.register('api2DevdockTusCustomRequestHeaders', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusRequestIdHeaders', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRequestIdHeaders' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusUploadCallbacks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusUploadCallbacks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index d311f2c5..da9ffa42 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -139,6 +139,14 @@ static Map uploadHeaders(JSONObject uploadConfig) { return stringMap(uploadConfig.getJSONObject("headers")); } + static boolean uploadAddRequestId(JSONObject uploadConfig) { + return uploadConfig.getBoolean("addRequestId"); + } + + static String uploadRequestIdHeaderName(JSONObject uploadConfig) { + return uploadConfig.getString("requestIdHeaderName"); + } + static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) { return new UploadCallbacksPlan( scenario.getJSONObject("upload").getJSONObject("uploadCallbacks") diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java new file mode 100644 index 00000000..193d712c --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java @@ -0,0 +1,141 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusRequestIdHeaders { + /** + * Run the API2 devdock TUS request ID headers example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithRequestIdHeaders(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed request ID headers for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRequestIdHeaders( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final String requestIdHeaderName = + Api2DevdockScenario.uploadRequestIdHeaderName(uploadConfig); + final Map> headersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setHeaders(Api2DevdockScenario.uploadHeaders(uploadConfig)); + if (Api2DevdockScenario.uploadAddRequestId(uploadConfig)) { + client.enableRequestIdHeader(); + } + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if ("POST".equals(context.getMethod()) || "PATCH".equals(context.getMethod())) { + final Map headers = new LinkedHashMap(); + headers.put( + requestIdHeaderName, + observedRequestIdHeader( + context.getConnection(), + context.getMethod(), + requestIdHeaderName + ) + ); + headersByMethod.put(context.getMethod(), headers); + } + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-request-id-headers"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "request ID headers upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("request ID headers upload did not expose a URL"); + } + + return new JSONObject() + .put("headersByMethod", new JSONObject(headersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static String observedRequestIdHeader( + HttpURLConnection connection, + String method, + String requestIdHeaderName + ) { + final String value = connection.getRequestProperty(requestIdHeaderName); + if (value == null || value.isEmpty()) { + throw new IllegalStateException( + "request ID headers did not observe " + + requestIdHeaderName + + " on " + + method + ); + } + + return value; + } + + private Api2DevdockTusRequestIdHeaders() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 9a911c23..c74eb6b8 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -23,6 +23,7 @@ public class TusClient { private Proxy proxy; private boolean resumingEnabled; private boolean removeFingerprintOnSuccessEnabled; + private boolean addRequestId; private TusURLStore urlStore; private Map headers; private int connectTimeout = 5000; @@ -165,6 +166,29 @@ public Map getHeaders() { return headers; } + /** + * Enable generated request IDs for every HTTP request made by this TusClient instance. + */ + public void enableRequestIdHeader() { + addRequestId = true; + } + + /** + * Disable generated request IDs for every HTTP request made by this TusClient instance. + */ + public void disableRequestIdHeader() { + addRequestId = false; + } + + /** + * Get the current generated request ID header setting. + * + * @return True if generated request IDs are enabled. + */ + public boolean requestIdHeaderEnabled() { + return addRequestId; + } + /** * Set request lifecycle callbacks for every HTTP request/response pair. * @@ -391,15 +415,7 @@ public void prepareConnection(@NotNull HttpURLConnection connection) { connection.setInstanceFollowRedirects(Boolean.getBoolean("http.strictPostRedirect")); connection.setConnectTimeout(connectTimeout); - for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { - connection.addRequestProperty(entry.getKey(), entry.getValue()); - } - - if (headers != null) { - for (Map.Entry entry : headers.entrySet()) { - connection.addRequestProperty(entry.getKey(), entry.getValue()); - } - } + TusProtocol.prepareRequestHeaders(connection, headers, addRequestId); } final void runBeforeRequest( diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 06de842a..2295a114 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -6,9 +6,11 @@ package io.tus.java.client; +import java.net.HttpURLConnection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.UUID; /** * Generated TUS protocol constants used by the runtime client. @@ -17,10 +19,21 @@ final class TusProtocol { static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final String REQUEST_ID_HEADER_NAME = "X-Request-ID"; private TusProtocol() { } + static void prepareRequestHeaders( + HttpURLConnection connection, + Map customHeaders, + boolean addRequestId + ) { + addDefaultRequestHeaders(connection); + addCustomRequestHeaders(connection, customHeaders); + addRequestIdHeader(connection, addRequestId); + } + private static Map defaultRequestHeaders() { Map result = new LinkedHashMap(); result.put("Tus-Resumable", "1.0.0"); @@ -32,4 +45,31 @@ private static Map defaultResponseHeaders() { result.put("Tus-Resumable", "1.0.0"); return Collections.unmodifiableMap(result); } + + private static void addDefaultRequestHeaders(HttpURLConnection connection) { + for (Map.Entry entry : DEFAULT_REQUEST_HEADERS.entrySet()) { + connection.addRequestProperty(entry.getKey(), entry.getValue()); + } + } + + private static void addCustomRequestHeaders( + HttpURLConnection connection, + Map customHeaders + ) { + if (customHeaders == null) { + return; + } + + for (Map.Entry entry : customHeaders.entrySet()) { + connection.addRequestProperty(entry.getKey(), entry.getValue()); + } + } + + private static void addRequestIdHeader(HttpURLConnection connection, boolean addRequestId) { + if (!addRequestId) { + return; + } + + connection.setRequestProperty(REQUEST_ID_HEADER_NAME, UUID.randomUUID().toString()); + } } From f1400fcfcfc1698ce398b2f004f9b0a9093923aa Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 16:11:42 +0200 Subject: [PATCH 80/96] Add API2 upload body headers proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 11 ++ .../Api2DevdockTusUploadBodyHeaders.java | 148 ++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java diff --git a/example/build.gradle b/example/build.gradle index a68e950d..425fbcda 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -30,6 +30,12 @@ tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusUploadBodyHeaders', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusUploadBodyHeaders' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusCustomRequestHeaders', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusCustomRequestHeaders' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index da9ffa42..4f54cb6e 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -139,6 +139,17 @@ static Map uploadHeaders(JSONObject uploadConfig) { return stringMap(uploadConfig.getJSONObject("headers")); } + static Map> uploadBodyHeadersByMethod(JSONObject uploadConfig) { + final JSONObject bodyHeadersByMethod = uploadConfig.getJSONObject("bodyHeadersByMethod"); + final Map> result = + new LinkedHashMap>(); + for (String method : bodyHeadersByMethod.keySet()) { + result.put(method, stringMap(bodyHeadersByMethod.getJSONObject(method))); + } + + return result; + } + static boolean uploadAddRequestId(JSONObject uploadConfig) { return uploadConfig.getBoolean("addRequestId"); } diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java new file mode 100644 index 00000000..7c90f8f4 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java @@ -0,0 +1,148 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusUploadBodyHeaders { + /** + * Run the API2 devdock TUS upload body headers example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithBodyHeaders(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed upload body headers for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithBodyHeaders( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Map> expectedHeadersByMethod = + Api2DevdockScenario.uploadBodyHeadersByMethod(uploadConfig); + final Map> bodyHeadersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + final Map expectedHeaders = + expectedHeadersByMethod.get(context.getMethod()); + if (expectedHeaders == null) { + return; + } + + bodyHeadersByMethod.put( + context.getMethod(), + observedBodyHeaders( + context.getConnection(), + context.getMethod(), + expectedHeaders + ) + ); + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-upload-body-headers"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "upload body headers upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("upload body headers upload did not expose a URL"); + } + for (String method : expectedHeadersByMethod.keySet()) { + if (!bodyHeadersByMethod.containsKey(method)) { + throw new IllegalStateException( + "upload body headers did not observe " + method + " request" + ); + } + } + + return new JSONObject() + .put("bodyHeadersByMethod", new JSONObject(bodyHeadersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static Map observedBodyHeaders( + HttpURLConnection connection, + String method, + Map expectedHeaders + ) { + final Map headers = new LinkedHashMap(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + final String value = connection.getRequestProperty(entry.getKey()); + if (value == null) { + throw new IllegalStateException( + "upload body headers did not observe " + entry.getKey() + " on " + method + ); + } + + headers.put(entry.getKey(), value); + } + + return headers; + } + + private Api2DevdockTusUploadBodyHeaders() { + throw new IllegalStateException("Utility class"); + } +} From 74eae019353821df83831a4df67b637fe12b06a9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 17:04:10 +0200 Subject: [PATCH 81/96] Add TUS terminate upload proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 20 +++ .../Api2DevdockTusTerminateUpload.java | 168 ++++++++++++++++++ .../java/io/tus/java/client/TusClient.java | 45 ++++- .../java/io/tus/java/client/TusProtocol.java | 10 ++ .../java/io/tus/java/client/TusUploader.java | 10 +- 6 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java diff --git a/example/build.gradle b/example/build.gradle index 425fbcda..2b319ea4 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -48,6 +48,12 @@ tasks.register('api2DevdockTusRequestIdHeaders', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusTerminateUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusTerminateUpload' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusUploadCallbacks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusUploadCallbacks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index 4f54cb6e..25fdc8af 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -51,6 +51,22 @@ static final class UploadCallbacksPlan { } } + static final class TerminationPlan { + final int expectedVerificationStatus; + final String method; + final int minimumDeleteRequestCount; + final int stopAfterAcceptedBytes; + final String verificationMethod; + + TerminationPlan(JSONObject termination) { + expectedVerificationStatus = termination.getInt("expectedVerificationStatus"); + method = termination.getString("method"); + minimumDeleteRequestCount = termination.getInt("minimumDeleteRequestCount"); + stopAfterAcceptedBytes = termination.getInt("stopAfterAcceptedBytes"); + verificationMethod = termination.getString("verificationMethod"); + } + } + static JSONObject loadScenario() throws IOException { String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); if (scenarioPath == null || scenarioPath.isEmpty()) { @@ -164,6 +180,10 @@ static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) { ); } + static TerminationPlan termination(JSONObject uploadConfig) { + return new TerminationPlan(uploadConfig.getJSONObject("termination")); + } + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { final StringBuilder key = new StringBuilder(); for (int index = 0; index < parts.length; index++) { diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java new file mode 100644 index 00000000..29f0ef46 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java @@ -0,0 +1,168 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusTerminateUpload { + /** + * Run the API2 devdock TUS terminate-upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadAndTerminate(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " terminated " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadAndTerminate( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final Api2DevdockScenario.TerminationPlan termination = + Api2DevdockScenario.termination(uploadConfig); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final List requestMethods = new ArrayList(); + + if (termination.stopAfterAcceptedBytes > content.length) { + throw new IllegalStateException( + "terminate upload stop-after bytes " + + termination.stopAfterAcceptedBytes + + " exceeds content length " + + content.length + ); + } + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + requestMethods.add(context.getMethod()); + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-terminate-upload"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(chunkSize); + uploader.setRequestPayloadSize(termination.stopAfterAcceptedBytes); + final int uploadedChunkSize = uploader.uploadChunk(); + uploader.finish(); + + if (uploadedChunkSize != termination.stopAfterAcceptedBytes) { + throw new IllegalStateException( + "terminate upload wrote " + + uploadedChunkSize + + " bytes, expected " + + termination.stopAfterAcceptedBytes + ); + } + if (uploader.getOffset() != termination.stopAfterAcceptedBytes) { + throw new IllegalStateException( + "terminate upload accepted " + + uploader.getOffset() + + " bytes, expected " + + termination.stopAfterAcceptedBytes + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("terminate upload did not expose a URL"); + } + + final URL uploadUrl = uploader.getUploadURL(); + client.terminateUpload(uploadUrl).disconnect(); + final int verificationStatus = verifyTerminatedUpload( + client, + termination.verificationMethod, + uploadUrl + ); + if (verificationStatus != termination.expectedVerificationStatus) { + throw new IllegalStateException( + "terminate upload verification status " + + verificationStatus + + ", expected " + + termination.expectedVerificationStatus + ); + } + + return new JSONObject() + .put("acceptedBytes", (int) uploader.getOffset()) + .put("deleteRequestCount", countMethod(requestMethods, termination.method)) + .put("requestMethods", new JSONArray(requestMethods)) + .put("terminated", true) + .put("uploadUrl", uploadUrl.toString()) + .put("verificationStatus", verificationStatus); + } + + private static int verifyTerminatedUpload( + TusClient client, + String method, + URL uploadUrl + ) throws IOException { + final HttpURLConnection connection = (HttpURLConnection) uploadUrl.openConnection(); + try { + connection.setRequestMethod(method); + client.prepareConnection(connection); + connection.connect(); + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } + + private static int countMethod(List methods, String expectedMethod) { + int count = 0; + for (String method : methods) { + if (method.equals(expectedMethod)) { + count += 1; + } + } + + return count; + } + + private Api2DevdockTusTerminateUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index c74eb6b8..ff750761 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -239,7 +239,7 @@ public int getConnectTimeout() { */ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolException, IOException { HttpURLConnection connection = openConnection(uploadCreationURL); - connection.setRequestMethod("POST"); + connection.setRequestMethod(TusProtocol.CREATE_UPLOAD_METHOD); prepareConnection(connection); String encodedMetadata = upload.getEncodedMetadata(); @@ -252,12 +252,12 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept } else { connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize())); } - runBeforeRequest("POST", connection); + runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); connection.connect(); int responseCode = connection.getResponseCode(); - runAfterResponse("POST", connection); - if (!(responseCode >= 200 && responseCode < 300)) { + runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { throw new ProtocolException( "unexpected status code (" + responseCode + ") while creating upload", connection); } @@ -279,6 +279,35 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept return createUploader(upload, uploadURL, 0L); } + /** + * Terminate an upload URL using the Termination extension. + * + * @param uploadURL The upload location URL to terminate. + * @return The completed HTTP connection. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public HttpURLConnection terminateUpload(@NotNull URL uploadURL) + throws ProtocolException, IOException { + HttpURLConnection connection = openConnection(uploadURL); + connection.setRequestMethod(TusProtocol.TERMINATE_UPLOAD_METHOD); + prepareConnection(connection); + + runBeforeRequest(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); + connection.connect(); + + int responseCode = connection.getResponseCode(); + runAfterResponse(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw new ProtocolException( + "unexpected status code (" + responseCode + ") while terminating upload", + connection); + } + + return connection; + } + @NotNull private HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOException { if (proxy != null) { @@ -346,15 +375,15 @@ public TusUploader resumeUpload(@NotNull TusUpload upload) throws public TusUploader beginOrResumeUploadFromURL(@NotNull TusUpload upload, @NotNull URL uploadURL) throws ProtocolException, IOException { HttpURLConnection connection = openConnection(uploadURL); - connection.setRequestMethod("HEAD"); + connection.setRequestMethod(TusProtocol.OFFSET_DISCOVERY_METHOD); prepareConnection(connection); - runBeforeRequest("HEAD", connection); + runBeforeRequest(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); connection.connect(); int responseCode = connection.getResponseCode(); - runAfterResponse("HEAD", connection); - if (!(responseCode >= 200 && responseCode < 300)) { + runAfterResponse(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { throw new ProtocolException( "unexpected status code (" + responseCode + ") while resuming upload", connection); } diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 2295a114..18b1775a 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -16,14 +16,24 @@ * Generated TUS protocol constants used by the runtime client. */ final class TusProtocol { + static final String CREATE_UPLOAD_METHOD = "POST"; static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final String OFFSET_DISCOVERY_METHOD = "HEAD"; static final String REQUEST_ID_HEADER_NAME = "X-Request-ID"; + static final int SUCCESS_RESPONSE_STATUS_CATEGORY = 200; + static final String TERMINATE_UPLOAD_METHOD = "DELETE"; + static final String UPLOAD_CHUNK_METHOD = "PATCH"; private TusProtocol() { } + static boolean isSuccessfulResponseStatus(int responseStatusCode) { + return responseStatusCode >= SUCCESS_RESPONSE_STATUS_CATEGORY + && responseStatusCode < SUCCESS_RESPONSE_STATUS_CATEGORY + 100; + } + static void prepareRequestHeaders( HttpURLConnection connection, Map customHeaders, diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index d8ce5202..0a4df1e0 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -117,16 +117,16 @@ private void openConnection() throws IOException, ProtocolException { connection.setRequestProperty("Expect", "100-continue"); try { - connection.setRequestMethod("PATCH"); + connection.setRequestMethod(TusProtocol.UPLOAD_CHUNK_METHOD); // Check whether we are running on a buggy JRE } catch (java.net.ProtocolException pe) { connection.setRequestMethod("POST"); - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + connection.setRequestProperty("X-HTTP-Method-Override", TusProtocol.UPLOAD_CHUNK_METHOD); } connection.setDoOutput(true); connection.setChunkedStreamingMode(0); - client.runBeforeRequest("PATCH", connection); + client.runBeforeRequest(TusProtocol.UPLOAD_CHUNK_METHOD, connection); try { output = connection.getOutputStream(); } catch (java.net.ProtocolException pe) { @@ -415,9 +415,9 @@ private void finishConnection() throws ProtocolException, IOException { HttpURLConnection currentConnection = connection; try { int responseCode = currentConnection.getResponseCode(); - client.runAfterResponse("PATCH", currentConnection); + client.runAfterResponse(TusProtocol.UPLOAD_CHUNK_METHOD, currentConnection); - if (!(responseCode >= 200 && responseCode < 300)) { + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { throw new ProtocolException("unexpected status code (" + responseCode + ") while uploading chunk", currentConnection); } From 61e3e2819b5e6d5d997e6e6a84f2680e1f7c1a58 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 17:52:41 +0200 Subject: [PATCH 82/96] Add TUS creation-with-upload proof --- example/build.gradle | 6 + .../Api2DevdockTusCreationWithUpload.java | 87 ++++++++ .../java/io/tus/java/client/TusClient.java | 188 ++++++++++++++++-- .../java/io/tus/java/client/TusProtocol.java | 7 + .../java/io/tus/java/client/TusUploader.java | 30 ++- .../io/tus/java/client/TestTusClient.java | 53 +++++ 6 files changed, 349 insertions(+), 22 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java diff --git a/example/build.gradle b/example/build.gradle index 2b319ea4..5c45a794 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -18,6 +18,12 @@ tasks.register('api2DevdockTusResumeUpload', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusCreationWithUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusCreationWithUpload' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRetryOffsetRecovery' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java new file mode 100644 index 00000000..993961b3 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java @@ -0,0 +1,87 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusCreationWithUpload { + /** + * Run the API2 devdock TUS creation-with-upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final String uploadUrl = uploadWithCreationData(scenario, createResponse); + Api2DevdockScenario.writeResult(new JSONObject().put("uploadUrl", uploadUrl)); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " uploaded to " + + uploadUrl + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static String uploadWithCreationData( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + if (!uploadConfig.getBoolean("uploadDataDuringCreation")) { + throw new IllegalStateException( + "creation-with-upload scenario must set uploadDataDuringCreation" + ); + } + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-creation-with-upload"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.createUploadWithData(upload, content.length); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "creation-with-upload accepted " + + uploader.getOffset() + + " bytes, expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("creation-with-upload did not expose a URL"); + } + + return uploader.getUploadURL().toString(); + } + + private Api2DevdockTusCreationWithUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index ff750761..be077f22 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -1,13 +1,14 @@ package io.tus.java.client; -import java.net.Proxy; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - +import java.io.EOFException; import java.io.IOException; +import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.Proxy; import java.net.URL; import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * This class is used for creating or resuming uploads. @@ -238,22 +239,72 @@ public int getConnectTimeout() { * @throws IOException Thrown if an exception occurs while issuing the HTTP request. */ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolException, IOException { + return createUpload(upload, 0); + } + + /** + * Create a new upload and send the first bytes in the creation request using the + * Creation With Upload extension. Before calling this function, an "upload creation URL" + * must be defined using {@link #setUploadCreationURL(URL)} or else this function will fail. + * + * @param upload The file for which a new upload will be created + * @param bytesToUpload Number of bytes to include in the creation request body + * @return Use {@link TusUploader} to upload any remaining file chunks. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes or missing/invalid headers. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public TusUploader createUploadWithData( + @NotNull TusUpload upload, + int bytesToUpload + ) throws ProtocolException, IOException { + if (bytesToUpload < 0) { + throw new IllegalArgumentException("creation upload byte count must not be negative"); + } + if (bytesToUpload == 0) { + return createUpload(upload); + } + if (upload.isUploadLengthDeferred()) { + throw new IllegalArgumentException( + "creation with upload requires a known upload length" + ); + } + if (bytesToUpload > upload.getSize()) { + throw new IllegalArgumentException( + "creation upload byte count " + + bytesToUpload + + " exceeds upload size " + + upload.getSize() + ); + } + + return createUpload(upload, bytesToUpload); + } + + private TusUploader createUpload( + @NotNull TusUpload upload, + int bytesToUpload + ) throws ProtocolException, IOException { HttpURLConnection connection = openConnection(uploadCreationURL); connection.setRequestMethod(TusProtocol.CREATE_UPLOAD_METHOD); prepareConnection(connection); - - String encodedMetadata = upload.getEncodedMetadata(); - if (encodedMetadata.length() > 0) { - connection.setRequestProperty("Upload-Metadata", encodedMetadata); + prepareUploadCreationHeaders(connection, upload); + + if (bytesToUpload > 0) { + connection.setRequestProperty( + TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + TusProtocol.UPLOAD_BODY_CONTENT_TYPE + ); + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(bytesToUpload); } - if (upload.isUploadLengthDeferred()) { - connection.addRequestProperty("Upload-Defer-Length", "1"); + runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); + if (bytesToUpload > 0) { + writeUploadCreationData(connection, upload, bytesToUpload); } else { - connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize())); + connection.connect(); } - runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); - connection.connect(); int responseCode = connection.getResponseCode(); runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); @@ -262,7 +313,7 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept "unexpected status code (" + responseCode + ") while creating upload", connection); } - String urlStr = connection.getHeaderField("Location"); + String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); if (urlStr == null || urlStr.length() == 0) { throw new ProtocolException("missing upload URL in response for creating upload", connection); } @@ -272,11 +323,96 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept // but there may be cases in which the POST request is redirected. URL uploadURL = new URL(connection.getURL(), urlStr); + long offset = bytesToUpload > 0 + ? readUploadCreationOffset(connection, bytesToUpload) + : 0L; + if (resumingEnabled) { urlStore.set(upload.getFingerprint(), uploadURL); } - return createUploader(upload, uploadURL, 0L); + return createUploader(upload, uploadURL, offset, bytesToUpload > 0); + } + + private static void prepareUploadCreationHeaders( + @NotNull HttpURLConnection connection, + @NotNull TusUpload upload + ) { + String encodedMetadata = upload.getEncodedMetadata(); + if (encodedMetadata.length() > 0) { + connection.setRequestProperty(TusProtocol.METADATA_HEADER_NAME, encodedMetadata); + } + + if (upload.isUploadLengthDeferred()) { + connection.addRequestProperty(TusProtocol.UPLOAD_DEFER_LENGTH_HEADER_NAME, "1"); + } else { + connection.addRequestProperty( + TusProtocol.UPLOAD_LENGTH_HEADER_NAME, + Long.toString(upload.getSize()) + ); + } + } + + private static void writeUploadCreationData( + @NotNull HttpURLConnection connection, + @NotNull TusUpload upload, + int bytesToUpload + ) throws IOException { + TusInputStream input = upload.getTusInputStream(); + + byte[] buffer = new byte[Math.min(bytesToUpload, 8192)]; + int bytesRemaining = bytesToUpload; + try (OutputStream output = connection.getOutputStream()) { + while (bytesRemaining > 0) { + int bytesRead = input.read(buffer, Math.min(buffer.length, bytesRemaining)); + if (bytesRead == -1) { + throw new EOFException( + "upload source ended before creation request wrote " + + bytesToUpload + + " bytes" + ); + } + + output.write(buffer, 0, bytesRead); + bytesRemaining -= bytesRead; + } + } + } + + private static long readUploadCreationOffset( + @NotNull HttpURLConnection connection, + int bytesToUpload + ) throws ProtocolException { + String offsetStr = connection.getHeaderField(TusProtocol.UPLOAD_OFFSET_HEADER_NAME); + if (offsetStr == null || offsetStr.length() == 0) { + throw new ProtocolException( + "missing upload offset in response for creating upload with data", + connection + ); + } + + long offset; + try { + offset = Long.parseLong(offsetStr); + } catch (NumberFormatException e) { + throw new ProtocolException( + "invalid upload offset in response for creating upload with data", + connection + ); + } + + if (offset != bytesToUpload) { + throw new ProtocolException( + "response contains different Upload-Offset value (" + + offset + + ") than expected (" + + bytesToUpload + + ")", + connection + ); + } + + return offset; } /** @@ -319,7 +455,25 @@ private HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOExcept @NotNull private TusUploader createUploader(@NotNull TusUpload upload, @NotNull URL uploadURL, long offset) throws IOException { - TusUploader uploader = new TusUploader(this, upload, uploadURL, upload.getTusInputStream(), offset); + return createUploader(upload, uploadURL, offset, false); + } + + @NotNull + private TusUploader createUploader( + @NotNull TusUpload upload, + @NotNull URL uploadURL, + long offset, + boolean inputAlreadyAtOffset + ) + throws IOException { + TusUploader uploader = new TusUploader( + this, + upload, + uploadURL, + upload.getTusInputStream(), + offset, + inputAlreadyAtOffset + ); uploader.setProxy(proxy); return uploader; } @@ -388,7 +542,7 @@ public TusUploader beginOrResumeUploadFromURL(@NotNull TusUpload upload, @NotNul "unexpected status code (" + responseCode + ") while resuming upload", connection); } - String offsetStr = connection.getHeaderField("Upload-Offset"); + String offsetStr = connection.getHeaderField(TusProtocol.UPLOAD_OFFSET_HEADER_NAME); if (offsetStr == null || offsetStr.length() == 0) { throw new ProtocolException("missing upload offset in response for resuming upload", connection); } diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 18b1775a..8feb5275 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -20,11 +20,18 @@ final class TusProtocol { static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final String LOCATION_HEADER_NAME = "Location"; + static final String METADATA_HEADER_NAME = "Upload-Metadata"; static final String OFFSET_DISCOVERY_METHOD = "HEAD"; static final String REQUEST_ID_HEADER_NAME = "X-Request-ID"; static final int SUCCESS_RESPONSE_STATUS_CATEGORY = 200; static final String TERMINATE_UPLOAD_METHOD = "DELETE"; + static final String UPLOAD_BODY_CONTENT_TYPE = "application/offset+octet-stream"; + static final String UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = "Content-Type"; static final String UPLOAD_CHUNK_METHOD = "PATCH"; + static final String UPLOAD_DEFER_LENGTH_HEADER_NAME = "Upload-Defer-Length"; + static final String UPLOAD_LENGTH_HEADER_NAME = "Upload-Length"; + static final String UPLOAD_OFFSET_HEADER_NAME = "Upload-Offset"; private TusProtocol() { } diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index 0a4df1e0..da73f5b8 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -77,6 +77,18 @@ public interface ChunkCompleteListener { * @throws IOException Thrown if an exception occurs while issuing the HTTP request. */ public TusUploader(TusClient client, TusUpload upload, URL uploadURL, TusInputStream input, long offset) + throws IOException { + this(client, upload, uploadURL, input, offset, false); + } + + TusUploader( + TusClient client, + TusUpload upload, + URL uploadURL, + TusInputStream input, + long offset, + boolean inputAlreadyAtOffset + ) throws IOException { this.uploadURL = uploadURL; this.input = input; @@ -85,7 +97,9 @@ public TusUploader(TusClient client, TusUpload upload, URL uploadURL, TusInputSt this.upload = upload; uploadLengthDeclared = !upload.isUploadLengthDeferred(); - input.seekTo(offset); + if (!inputAlreadyAtOffset) { + input.seekTo(offset); + } setChunkSize(2 * 1024 * 1024); } @@ -108,12 +122,15 @@ private void openConnection() throws IOException, ProtocolException { connection = (HttpURLConnection) uploadURL.openConnection(); } client.prepareConnection(connection); - connection.setRequestProperty("Upload-Offset", Long.toString(offset)); + connection.setRequestProperty(TusProtocol.UPLOAD_OFFSET_HEADER_NAME, Long.toString(offset)); if (shouldDeclareUploadLength()) { - connection.setRequestProperty("Upload-Length", Long.toString(upload.getSize())); + connection.setRequestProperty(TusProtocol.UPLOAD_LENGTH_HEADER_NAME, Long.toString(upload.getSize())); requestDeclaresUploadLength = true; } - connection.setRequestProperty("Content-Type", "application/offset+octet-stream"); + connection.setRequestProperty( + TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + TusProtocol.UPLOAD_BODY_CONTENT_TYPE + ); connection.setRequestProperty("Expect", "100-continue"); try { @@ -423,7 +440,10 @@ private void finishConnection() throws ProtocolException, IOException { } // TODO detect changes and seek accordingly - long serverOffset = getHeaderFieldLong(currentConnection, "Upload-Offset"); + long serverOffset = getHeaderFieldLong( + currentConnection, + TusProtocol.UPLOAD_OFFSET_HEADER_NAME + ); if (serverOffset == -1) { throw new ProtocolException("response to PATCH request contains no or invalid Upload-Offset header", currentConnection); diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index e5f57495..15347bf1 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -9,6 +9,7 @@ import java.net.Proxy.Type; import java.net.URL; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -18,6 +19,7 @@ import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -108,6 +110,57 @@ public void testCreateUpload() throws IOException, ProtocolException { assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); } + /** + * Verifies if uploads can be created while sending data in the creation request. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreateUploadWithData() throws IOException, ProtocolException { + byte[] content = new byte[] { + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j' + }; + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Connection", "keep-alive") + .withHeader("Content-Type", "application/offset+octet-stream") + .withHeader("Upload-Length", "10") + .withBody(Arrays.copyOfRange(content, 0, 4)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/foo") + .withHeader("Upload-Offset", "4"))); + mockServer.when(new HttpRequest() + .withMethod("POST") + .withPath("/files/foo")) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .withHeader("Upload-Offset", "10"))); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(content.length); + upload.setInputStream(new ByteArrayInputStream(content)); + + TusUploader uploader = client.createUploadWithData(upload, 4); + assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); + assertEquals(uploader.getOffset(), 4); + + uploader.setChunkSize(6); + assertEquals(uploader.uploadChunk(), 6); + uploader.finish(); + assertEquals(uploader.getOffset(), content.length); + + HttpRequest[] patchRequests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("POST") + .withPath("/files/foo")); + assertEquals(1, patchRequests.length); + assertTrue(patchRequests[0].containsHeader("X-HTTP-Method-Override")); + assertArrayEquals(Arrays.copyOfRange(content, 4, 10), patchRequests[0].getBodyAsRawBytes()); + } + /** * Verifies if request lifecycle hooks run around upload creation. * @throws IOException if upload data cannot be read. From bdee35818696f4cfe1e6a893f1c7ecbb383d14a5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 00:29:12 +0200 Subject: [PATCH 83/96] Add deferred-length TUS devdock proof --- example/build.gradle | 6 ++ .../Api2DevdockTusDeferredLengthUpload.java | 90 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java diff --git a/example/build.gradle b/example/build.gradle index 5c45a794..ae3f3c85 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -24,6 +24,12 @@ tasks.register('api2DevdockTusCreationWithUpload', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusDeferredLengthUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusDeferredLengthUpload' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRetryOffsetRecovery' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java new file mode 100644 index 00000000..6d6fb16f --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java @@ -0,0 +1,90 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusDeferredLengthUpload { + /** + * Run the API2 devdock TUS deferred-length upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final String uploadUrl = uploadWithDeferredLength(scenario, createResponse); + Api2DevdockScenario.writeResult(new JSONObject().put("uploadUrl", uploadUrl)); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " uploaded with deferred length to " + + uploadUrl + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static String uploadWithDeferredLength( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + if (!uploadConfig.getBoolean("uploadLengthDeferred")) { + throw new IllegalStateException( + "deferred-length scenario must set uploadLengthDeferred" + ); + } + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-deferred-length"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + upload.setUploadLengthDeferred(true); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(chunkSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "remote offset " + uploader.getOffset() + ", expected " + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("deferred-length upload did not return a URL"); + } + + return uploader.getUploadURL().toString(); + } + + private Api2DevdockTusDeferredLengthUpload() { + throw new IllegalStateException("Utility class"); + } +} From f23b8cff56082b41a05d71e3fb6bc9ceb3e4ac31 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 03:53:40 +0200 Subject: [PATCH 84/96] Add relative Location conformance proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 57 ++++ .../Api2DevdockTusConformanceServer.java | 249 ++++++++++++++++++ ...2DevdockTusRelativeLocationResolution.java | 100 +++++++ 4 files changed, 412 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java diff --git a/example/build.gradle b/example/build.gradle index ae3f3c85..2bb940c1 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -71,3 +71,9 @@ tasks.register('api2DevdockTusUploadCallbacks', JavaExec) { mainClass = 'io.tus.java.example.Api2DevdockTusUploadCallbacks' workingDir = rootProject.projectDir } + +tasks.register('api2DevdockTusRelativeLocationResolution', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRelativeLocationResolution' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index 25fdc8af..993fac83 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -93,6 +93,37 @@ static JSONObject createResponse(JSONObject scenario) { return scenario.getJSONObject("prepared").getJSONObject("createResponse"); } + static JSONObject conformanceScenario(JSONObject scenario) { + return scenario.getJSONObject("conformanceScenario"); + } + + static byte[] conformanceInputSourceBytes(JSONObject conformanceScenario) { + final JSONObject inputSource = conformanceScenario.getJSONObject("inputSource"); + final String kind = inputSource.getString("kind"); + if (!"blob".equals(kind)) { + throw new IllegalArgumentException("unsupported conformance input source kind " + kind); + } + + return inputSource.getString("content").getBytes(StandardCharsets.UTF_8); + } + + static Map conformanceInputStringMapOption( + JSONObject conformanceScenario, + String key + ) { + final JSONObject values = conformanceInputJSONObjectOption(conformanceScenario, key); + final Map result = new LinkedHashMap(); + for (String name : values.keySet()) { + result.put(name, scalarString(values.get(name))); + } + + return result; + } + + static String conformanceInputStringOption(JSONObject conformanceScenario, String key) { + return scalarString(conformanceInputOption(conformanceScenario, key)); + } + static byte[] scenarioBytes(JSONObject uploadConfig) { final JSONObject source = uploadConfig.getJSONObject("source"); final String kind = source.getString("kind"); @@ -108,6 +139,32 @@ static byte[] scenarioBytes(JSONObject uploadConfig) { return source.getString("value").getBytes(StandardCharsets.UTF_8); } + private static Object conformanceInputOption(JSONObject conformanceScenario, String key) { + final JSONArray entries = conformanceScenario.getJSONArray("inputOptionEntries"); + for (int index = 0; index < entries.length(); index++) { + final JSONObject entry = entries.getJSONObject(index); + if (key.equals(entry.getString("key"))) { + return entry.get("value"); + } + } + + throw new IllegalArgumentException("missing conformance input option " + key); + } + + private static JSONObject conformanceInputJSONObjectOption( + JSONObject conformanceScenario, + String key + ) { + final Object value = conformanceInputOption(conformanceScenario, key); + if (!(value instanceof JSONObject)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not an object" + ); + } + + return (JSONObject) value; + } + static int fixedChunkSizeBytes(JSONObject uploadConfig) { final JSONObject chunkSize = uploadConfig.getJSONObject("chunkSize"); final String kind = chunkSize.getString("kind"); diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java new file mode 100644 index 00000000..350a662c --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java @@ -0,0 +1,249 @@ +package io.tus.java.example; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +final class Api2DevdockTusConformanceServer implements AutoCloseable { + private final URL endpointOrigin; + private final List requests; + private final HttpServer server; + private final List errors; + private final List requestMethods; + private final List requestUrls; + private int nextRequestIndex; + + Api2DevdockTusConformanceServer(JSONObject conformanceScenario, URL endpointOrigin) + throws IOException { + this.endpointOrigin = endpointOrigin; + this.requests = new ArrayList(); + final JSONArray requestArray = conformanceScenario.getJSONArray("requests"); + for (int index = 0; index < requestArray.length(); index++) { + requests.add(requestArray.getJSONObject(index)); + } + this.errors = new ArrayList(); + this.requestMethods = new ArrayList(); + this.requestUrls = new ArrayList(); + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + handleRequest(exchange); + } + }); + server.start(); + } + + URL endpointUrl() throws IOException { + return localUrl(endpointOrigin.toString()); + } + + void assertExhausted() { + assertNoErrors(); + if (nextRequestIndex == requests.size()) { + return; + } + + throw new IllegalStateException( + "expected " + + requests.size() + + " conformance request(s), got " + + nextRequestIndex + ); + } + + void assertNoErrors() { + if (errors.isEmpty()) { + return; + } + + throw new IllegalStateException(errorSummary()); + } + + String canonicalUrl(String actualUrl) { + return actualUrl.replace(localOrigin(), canonicalOrigin()); + } + + String errorSummary() { + if (errors.isEmpty()) { + return "no conformance server errors"; + } + + return String.join("; ", errors); + } + + JSONObject result() { + return new JSONObject() + .put("requestMethods", new JSONArray(requestMethods)) + .put("requestUrls", new JSONArray(requestUrls)); + } + + @Override + public void close() { + server.stop(0); + } + + private void handleRequest(HttpExchange exchange) throws IOException { + try { + final byte[] body = readRequestBody(exchange); + final int requestIndex = observeRequest(exchange, body); + writeResponse(exchange, requests.get(requestIndex)); + } catch (Exception error) { + errors.add(error.getMessage()); + final byte[] body = errorSummary().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(500, body.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(body); + } + } + } + + private int observeRequest(HttpExchange exchange, byte[] body) throws IOException { + if (nextRequestIndex >= requests.size()) { + throw new IllegalStateException( + "unexpected request " + + exchange.getRequestMethod() + + " " + + exchange.getRequestURI() + ); + } + + final int requestIndex = nextRequestIndex; + final JSONObject requestPlan = requests.get(requestIndex); + final String actualUrl = canonicalRequestUrl(exchange.getRequestURI()); + final String expectedUrl = requestPlan.getString("expectedUrl"); + final String expectedMethod = requestPlan.getString("effectiveMethod"); + if (!expectedMethod.equals(exchange.getRequestMethod())) { + throw new IllegalStateException( + "request " + + requestIndex + + " expected method " + + expectedMethod + + ", got " + + exchange.getRequestMethod() + ); + } + if (!expectedUrl.equals(actualUrl)) { + throw new IllegalStateException( + "request " + + requestIndex + + " expected URL " + + expectedUrl + + ", got " + + actualUrl + ); + } + if (!requestPlan.isNull("bodySize") && body.length != requestPlan.getInt("bodySize")) { + throw new IllegalStateException( + "request " + + requestIndex + + " expected body size " + + requestPlan.getInt("bodySize") + + ", got " + + body.length + ); + } + assertHeaders( + requestIndex, + requestPlan.getJSONObject("effectiveHeaders"), + exchange.getRequestHeaders() + ); + + requestMethods.add(exchange.getRequestMethod()); + requestUrls.add(actualUrl); + nextRequestIndex += 1; + + return requestIndex; + } + + private void assertHeaders(int requestIndex, JSONObject expectedHeaders, Headers actualHeaders) { + for (String name : expectedHeaders.keySet()) { + final String expectedValue = expectedHeaders.getString(name); + final String actualValue = actualHeaders.getFirst(name); + if (expectedValue.equals(actualValue)) { + continue; + } + + throw new IllegalStateException( + "request " + + requestIndex + + " expected header " + + name + + "=" + + expectedValue + + ", got " + + actualValue + ); + } + } + + private void writeResponse(HttpExchange exchange, JSONObject requestPlan) throws IOException { + final JSONObject responsePlan = requestPlan.getJSONObject("response"); + final Headers headers = exchange.getResponseHeaders(); + final JSONObject responseHeaders = responsePlan.getJSONObject("effectiveHeaders"); + for (String name : responseHeaders.keySet()) { + headers.set(name, localValue(responseHeaders.getString(name))); + } + + final byte[] body; + if (responsePlan.isNull("body")) { + body = new byte[0]; + } else { + body = responsePlan.getString("body").getBytes(StandardCharsets.UTF_8); + } + final long responseBodyLength = body.length == 0 ? -1 : body.length; + exchange.sendResponseHeaders(responsePlan.getInt("statusCode"), responseBodyLength); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(body); + } + } + + private URL localUrl(String canonicalUrl) throws IOException { + return new URL(localValue(canonicalUrl)); + } + + private String localValue(String value) { + return value.replace(canonicalOrigin(), localOrigin()); + } + + private String canonicalRequestUrl(URI requestUri) throws IOException { + return new URL(endpointOrigin, requestUri.toString()).toString(); + } + + private static byte[] readRequestBody(HttpExchange exchange) throws IOException { + final ByteArrayOutputStream body = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int read; + while ((read = exchange.getRequestBody().read(buffer)) != -1) { + body.write(buffer, 0, read); + } + + return body.toByteArray(); + } + + private String canonicalOrigin() { + return endpointOrigin.getProtocol() + "://" + endpointOrigin.getHost(); + } + + private String localOrigin() { + final InetSocketAddress address = server.getAddress(); + return "http://" + address.getHostString() + ":" + address.getPort(); + } + + private Api2DevdockTusConformanceServer() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java new file mode 100644 index 00000000..4d2c2412 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java @@ -0,0 +1,100 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusRelativeLocationResolution { + /** + * Run the API2 devdock TUS relative Location resolution example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = uploadWithRelativeLocationResolution(conformanceScenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " resolved " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRelativeLocationResolution(JSONObject conformanceScenario) + throws IOException, ProtocolException { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + ) + ); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint("api2-java-relative-location-conformance-fingerprint"); + upload.setMetadata( + Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ) + ); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(content.length); + try { + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + } catch (IOException | ProtocolException error) { + throw new IOException( + "relative Location conformance failed: " + + conformanceServer.errorSummary(), + error + ); + } + + if (uploader.getUploadURL() == null) { + throw new IllegalStateException( + "relative Location resolution upload did not expose a URL" + ); + } + + conformanceServer.assertExhausted(); + return conformanceServer.result() + .put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + } + } + + private Api2DevdockTusRelativeLocationResolution() { + throw new IllegalStateException("Utility class"); + } +} From 005bbd35707c605d81aa6bca426af617175d80e4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 04:50:53 +0200 Subject: [PATCH 85/96] Add override PATCH conformance proof --- example/build.gradle | 6 ++ .../Api2DevdockTusConformanceServer.java | 27 +++++- .../Api2DevdockTusOverridePatchMethod.java | 91 +++++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java diff --git a/example/build.gradle b/example/build.gradle index 2bb940c1..2493d708 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -77,3 +77,9 @@ tasks.register('api2DevdockTusRelativeLocationResolution', JavaExec) { mainClass = 'io.tus.java.example.Api2DevdockTusRelativeLocationResolution' workingDir = rootProject.projectDir } + +tasks.register('api2DevdockTusOverridePatchMethod', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusOverridePatchMethod' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java index 350a662c..39c369db 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java @@ -22,6 +22,7 @@ final class Api2DevdockTusConformanceServer implements AutoCloseable { private final List requests; private final HttpServer server; private final List errors; + private final List requestHeaders; private final List requestMethods; private final List requestUrls; private int nextRequestIndex; @@ -35,6 +36,7 @@ final class Api2DevdockTusConformanceServer implements AutoCloseable { requests.add(requestArray.getJSONObject(index)); } this.errors = new ArrayList(); + this.requestHeaders = new ArrayList(); this.requestMethods = new ArrayList(); this.requestUrls = new ArrayList(); this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); @@ -51,6 +53,10 @@ URL endpointUrl() throws IOException { return localUrl(endpointOrigin.toString()); } + URL localUrlFor(String canonicalUrl) throws IOException { + return localUrl(canonicalUrl); + } + void assertExhausted() { assertNoErrors(); if (nextRequestIndex == requests.size()) { @@ -87,6 +93,7 @@ String errorSummary() { JSONObject result() { return new JSONObject() + .put("requestHeaders", new JSONArray(requestHeaders)) .put("requestMethods", new JSONArray(requestMethods)) .put("requestUrls", new JSONArray(requestUrls)); } @@ -156,14 +163,12 @@ private int observeRequest(HttpExchange exchange, byte[] body) throws IOExceptio + body.length ); } - assertHeaders( - requestIndex, - requestPlan.getJSONObject("effectiveHeaders"), - exchange.getRequestHeaders() - ); + final JSONObject expectedHeaders = requestPlan.getJSONObject("effectiveHeaders"); + assertHeaders(requestIndex, expectedHeaders, exchange.getRequestHeaders()); requestMethods.add(exchange.getRequestMethod()); requestUrls.add(actualUrl); + requestHeaders.add(capturedHeaders(expectedHeaders, exchange.getRequestHeaders())); nextRequestIndex += 1; return requestIndex; @@ -190,6 +195,18 @@ private void assertHeaders(int requestIndex, JSONObject expectedHeaders, Headers } } + private JSONObject capturedHeaders(JSONObject expectedHeaders, Headers actualHeaders) { + final JSONObject result = new JSONObject(); + for (String name : expectedHeaders.keySet()) { + final String value = actualHeaders.getFirst(name); + if (value != null) { + result.put(name, value); + } + } + + return result; + } + private void writeResponse(HttpExchange exchange, JSONObject requestPlan) throws IOException { final JSONObject responsePlan = requestPlan.getJSONObject("response"); final Headers headers = exchange.getResponseHeaders(); diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java new file mode 100644 index 00000000..8853f818 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java @@ -0,0 +1,91 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusOverridePatchMethod { + /** + * Run the API2 devdock TUS PATCH method override example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = uploadWithMethodOverride(conformanceScenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " uploaded to " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithMethodOverride(JSONObject conformanceScenario) + throws IOException, ProtocolException { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + ) + ); + final String uploadUrl = Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "uploadUrl" + ); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final TusClient client = new TusClient(); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + + final TusUploader uploader = client.beginOrResumeUploadFromURL( + upload, + conformanceServer.localUrlFor(uploadUrl) + ); + uploader.setChunkSize(content.length); + try { + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + } catch (IOException | ProtocolException error) { + throw new IOException( + "PATCH method override conformance failed: " + + conformanceServer.errorSummary(), + error + ); + } + + conformanceServer.assertExhausted(); + return conformanceServer.result() + .put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + } + } + + private Api2DevdockTusOverridePatchMethod() { + throw new IllegalStateException("Utility class"); + } +} From b1b3d073d05b714196373797960d67c1413ece74 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 05:05:53 +0200 Subject: [PATCH 86/96] Add file URL storage proof --- example/build.gradle | 6 + .../Api2DevdockTusFileUrlStorageBackend.java | 177 ++++++++++++++++++ .../io/tus/java/client/TusURLFileStore.java | 157 ++++++++++++++++ .../tus/java/client/TestTusURLFileStore.java | 44 +++++ 4 files changed, 384 insertions(+) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java create mode 100644 src/main/java/io/tus/java/client/TusURLFileStore.java create mode 100644 src/test/java/io/tus/java/client/TestTusURLFileStore.java diff --git a/example/build.gradle b/example/build.gradle index 2493d708..b39bc415 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -18,6 +18,12 @@ tasks.register('api2DevdockTusResumeUpload', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusFileUrlStorageBackend', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusFileUrlStorageBackend' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusCreationWithUpload', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusCreationWithUpload' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java new file mode 100644 index 00000000..291c6871 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java @@ -0,0 +1,177 @@ +package io.tus.java.example; + +import io.tus.java.client.FingerprintNotFoundException; +import io.tus.java.client.ProtocolException; +import io.tus.java.client.ResumingNotEnabledException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLFileStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusFileUrlStorageBackend { + /** + * Run the API2 devdock TUS file URL storage example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithFileStorage(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " resumed with file URL storage " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithFileStorage( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException, FingerprintNotFoundException, + ResumingNotEnabledException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject resume = uploadConfig.getJSONObject("resume"); + final JSONObject urlStorageBackend = uploadConfig.getJSONObject("urlStorageBackend"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final String fingerprint = resume.getString("fingerprint"); + final File storageFile = File.createTempFile("api2-devdock-tus-url-storage", ".properties"); + + try { + final TusURLFileStore store = new TusURLFileStore(storageFile); + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(store); + if (resume.getBoolean("removeFingerprintOnSuccess")) { + client.enableRemoveFingerprintOnSuccess(); + } + + final TusUpload firstUpload = uploadFor( + scenario, + createResponse, + content, + fingerprint + ); + final TusUploader firstUploader = client.createUpload(firstUpload); + firstUploader.setChunkSize(chunkSize); + final int firstAcceptedBytes = firstUploader.uploadChunk(); + firstUploader.finish(false); + + if (firstAcceptedBytes != resume.getInt("stopAfterAcceptedBytes")) { + throw new IllegalStateException( + "first upload accepted " + + firstAcceptedBytes + + " bytes, expected " + + resume.getInt("stopAfterAcceptedBytes") + ); + } + + final String firstUploadUrl = firstUploader.getUploadURL().toString(); + final int previousUploadCount = store.size(); + if (previousUploadCount != resume.getInt("expectedPreviousUploadCount")) { + throw new IllegalStateException( + "stored upload count " + + previousUploadCount + + ", expected " + + resume.getInt("expectedPreviousUploadCount") + ); + } + final boolean storedUploadKeyPrefixMatched = store.hasKeyWithPrefix( + urlStorageBackend.getString("expectedStoredUploadKeyPrefix") + ); + + final TusUpload secondUpload = uploadFor( + scenario, + createResponse, + content, + fingerprint + ); + final TusUploader resumedUploader = client.resumeUpload(secondUpload); + resumedUploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = resumedUploader.uploadChunk(); + } while (uploadedChunkSize > -1); + resumedUploader.finish(); + + final String uploadUrl = resumedUploader.getUploadURL().toString(); + if (!firstUploadUrl.equals(uploadUrl)) { + throw new IllegalStateException( + "resumed upload URL " + uploadUrl + ", expected " + firstUploadUrl + ); + } + if (resumedUploader.getOffset() != content.length) { + throw new IllegalStateException( + "remote offset " + + resumedUploader.getOffset() + + ", expected " + + content.length + ); + } + + final int remainingPreviousUploadCount = store.size(); + if (remainingPreviousUploadCount != resume.getInt("expectedRemainingPreviousUploadCount")) { + throw new IllegalStateException( + "remaining stored upload count " + + remainingPreviousUploadCount + + ", expected " + + resume.getInt("expectedRemainingPreviousUploadCount") + ); + } + + return new JSONObject() + .put("firstAcceptedBytes", firstAcceptedBytes) + .put("firstUploadUrl", firstUploadUrl) + .put("previousUploadCount", previousUploadCount) + .put("remainingPreviousUploadCount", remainingPreviousUploadCount) + .put("storageFileEntryCount", store.size()) + .put("storedUploadKeyPrefixMatched", storedUploadKeyPrefixMatched) + .put("uploadUrl", uploadUrl) + .put("urlStorageBackend", urlStorageBackend.getString("kind")); + } finally { + if (storageFile.exists() && !storageFile.delete()) { + storageFile.deleteOnExit(); + } + } + } + + private static TusUpload uploadFor( + JSONObject scenario, + JSONObject createResponse, + byte[] content, + String fingerprint + ) { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(fingerprint); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + return upload; + } + + private Api2DevdockTusFileUrlStorageBackend() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusURLFileStore.java b/src/main/java/io/tus/java/client/TusURLFileStore.java new file mode 100644 index 00000000..983cfb4b --- /dev/null +++ b/src/main/java/io/tus/java/client/TusURLFileStore.java @@ -0,0 +1,157 @@ +package io.tus.java.client; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +/** + * Persistent URL store backed by a local properties file. + */ +public class TusURLFileStore implements TusURLStore { + private static final String NAMESPACE = "tus"; + private static final String SEPARATOR = "::"; + + private final File file; + + /** + * Create a persistent URL store. + * + * @param file File used for storing upload URLs. + */ + public TusURLFileStore(@NotNull File file) { + this.file = file; + } + + /** + * Stores the upload's fingerprint and URL. + * + * @param fingerprint An upload's fingerprint. + * @param url The corresponding upload URL. + */ + @Override + public synchronized void set(String fingerprint, URL url) { + Properties properties = readProperties(); + properties.setProperty(newKey(fingerprint), url.toString()); + writeProperties(properties); + } + + /** + * Returns the first stored upload URL for a fingerprint. + * + * @param fingerprint An upload's fingerprint. + * @return The corresponding upload URL. + */ + @Override + public synchronized URL get(String fingerprint) { + Properties properties = readProperties(); + List keys = keysForFingerprint(properties, fingerprint); + if (keys.isEmpty()) { + return null; + } + + try { + return new URL(properties.getProperty(keys.get(0))); + } catch (MalformedURLException error) { + return null; + } + } + + /** + * Removes all stored upload URLs for a fingerprint. + * + * @param fingerprint An upload's fingerprint. + */ + @Override + public synchronized void remove(String fingerprint) { + Properties properties = readProperties(); + for (String key : keysForFingerprint(properties, fingerprint)) { + properties.remove(key); + } + writeProperties(properties); + } + + /** + * Returns the number of stored upload URLs. + * + * @return Stored upload URL count. + */ + public synchronized int size() { + return readProperties().size(); + } + + /** + * Returns whether a stored key starts with the given prefix. + * + * @param prefix Key prefix. + * @return True if a stored key starts with the prefix. + */ + public synchronized boolean hasKeyWithPrefix(String prefix) { + Properties properties = readProperties(); + for (Object key : properties.keySet()) { + if (String.valueOf(key).startsWith(prefix)) { + return true; + } + } + + return false; + } + + private String newKey(String fingerprint) { + return keyPrefix(fingerprint) + UUID.randomUUID().toString(); + } + + private static String keyPrefix(String fingerprint) { + return NAMESPACE + SEPARATOR + fingerprint + SEPARATOR; + } + + private static List keysForFingerprint(Properties properties, String fingerprint) { + final String prefix = keyPrefix(fingerprint); + final List result = new ArrayList(); + for (Object key : properties.keySet()) { + final String stringKey = String.valueOf(key); + if (stringKey.startsWith(prefix)) { + result.add(stringKey); + } + } + Collections.sort(result); + return result; + } + + private Properties readProperties() { + final Properties properties = new Properties(); + if (!file.exists()) { + return properties; + } + + try (FileInputStream input = new FileInputStream(file)) { + properties.load(input); + } catch (IOException error) { + throw new IllegalStateException("could not read TUS URL storage file", error); + } + + return properties; + } + + private void writeProperties(Properties properties) { + final File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IllegalStateException("could not create TUS URL storage directory"); + } + + try (FileOutputStream output = new FileOutputStream(file)) { + properties.store(output, "tus-java-client URL storage"); + } catch (IOException error) { + throw new IllegalStateException("could not write TUS URL storage file", error); + } + } +} diff --git a/src/test/java/io/tus/java/client/TestTusURLFileStore.java b/src/test/java/io/tus/java/client/TestTusURLFileStore.java new file mode 100644 index 00000000..695be515 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestTusURLFileStore.java @@ -0,0 +1,44 @@ +package io.tus.java.client; + +import org.junit.Test; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Test class for {@link TusURLFileStore}. + */ +public class TestTusURLFileStore { + /** + * Tests if file-backed URL storage persists and removes upload URLs. + * + * @throws Exception if the temporary file or URL cannot be created. + */ + @Test + public void test() throws Exception { + File file = Files.createTempFile("tus-url-store", ".properties").toFile(); + assertTrue(file.delete()); + + URL url = new URL("https://tusd.tusdemo.net/files/hello"); + TusURLFileStore store = new TusURLFileStore(file); + store.set("foo", url); + + assertEquals(url, store.get("foo")); + assertEquals(1, store.size()); + assertTrue(store.hasKeyWithPrefix("tus::foo::")); + + TusURLFileStore restoredStore = new TusURLFileStore(file); + assertEquals(url, restoredStore.get("foo")); + + restoredStore.remove("foo"); + assertNull(restoredStore.get("foo")); + assertEquals(0, restoredStore.size()); + + assertTrue(file.delete()); + } +} From 83efac20acfdea82b52b08968250190808852f2c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 05:27:57 +0200 Subject: [PATCH 87/96] Add retry state transition proof --- example/build.gradle | 6 + .../Api2DevdockTusRetryStateTransitions.java | 340 ++++++++++++++++++ .../java/io/tus/java/client/TusExecutor.java | 59 ++- .../io/tus/java/client/TestTusExecutor.java | 70 ++++ 4 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java diff --git a/example/build.gradle b/example/build.gradle index b39bc415..189cc790 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -42,6 +42,12 @@ tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusRetryStateTransitions', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRetryStateTransitions' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java new file mode 100644 index 00000000..b38e3ea5 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java @@ -0,0 +1,340 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusExecutor; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class Api2DevdockTusRetryStateTransitions { + private static final String UPLOAD_OFFSET_HEADER_NAME = "Upload-Offset"; + + /** + * Run the API2 devdock TUS retry-state transitions example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadWithRetryStateTransitions(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed " + + result.getInt("eventCount") + + " retry event(s) for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRetryStateTransitions(JSONObject scenario) + throws IOException, ProtocolException { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final int[] retryDelays = conformanceRetryDelays(conformanceScenario); + final RetryStateObserver observer = new RetryStateObserver( + retryDecisions(conformanceScenario), + retryDelays + ); + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) { + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + client.enableResuming(new TusURLMemoryStore()); + + final TusUpload upload = uploadFor(scenario, content, metadata); + final long[] lastAcceptedOffset = new long[]{0}; + final RetryStateExecutor executor = new RetryStateExecutor( + client, + upload, + content.length, + observer + ); + executor.setDelays(retryDelays); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + null, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) { + final long acceptedOffset = readAcceptedOffset(context.getConnection()); + if (acceptedOffset <= lastAcceptedOffset[0]) { + return; + } + + lastAcceptedOffset[0] = acceptedOffset; + executor.resetAfterProgress(); + } + } + )); + + if (!executor.makeAttempts()) { + throw new IOException("retry state transition upload was interrupted"); + } + observer.assertComplete(); + conformanceServer.assertExhausted(); + + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("eventCount", observer.events().length()); + result.put("events", observer.events()); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", true); + result.put("uploadUrl", conformanceServer.canonicalUrl(executor.uploadUrl())); + + return result; + } + } + + private static TusUpload uploadFor( + JSONObject scenario, + byte[] content, + Map metadata + ) { + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-retry-state-transitions"); + upload.setMetadata(metadata); + return upload; + } + + private static int[] conformanceRetryDelays(JSONObject conformanceScenario) { + final JSONArray values = conformanceInputJSONArrayOption(conformanceScenario, "retryDelays"); + final int[] result = new int[values.length()]; + for (int index = 0; index < values.length(); index++) { + result[index] = values.getInt(index); + } + + return result; + } + + private static JSONArray conformanceInputJSONArrayOption( + JSONObject conformanceScenario, + String key + ) { + final JSONArray entries = conformanceScenario.getJSONArray("inputOptionEntries"); + for (int index = 0; index < entries.length(); index++) { + final JSONObject entry = entries.getJSONObject(index); + if (key.equals(entry.getString("key"))) { + return entry.getJSONArray("value"); + } + } + + throw new IllegalArgumentException("missing conformance input array option " + key); + } + + private static List retryDecisions(JSONObject conformanceScenario) { + final JSONArray values = conformanceScenario.getJSONArray("retryDecisions"); + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + final JSONObject value = values.getJSONObject(index); + result.add(new RetryDecision( + value.getBoolean("decision"), + value.getInt("retryAttempt") + )); + } + + return result; + } + + private static long readAcceptedOffset(HttpURLConnection connection) { + final String value = connection.getHeaderField(UPLOAD_OFFSET_HEADER_NAME); + if (value == null || value.length() == 0) { + return -1; + } + + try { + return Long.parseLong(value); + } catch (NumberFormatException error) { + return -1; + } + } + + private static final class RetryStateExecutor extends TusExecutor { + private final TusClient client; + private final TusUpload upload; + private final int requestPayloadSize; + private final RetryStateObserver observer; + private String uploadUrl; + + RetryStateExecutor( + TusClient client, + TusUpload upload, + int requestPayloadSize, + RetryStateObserver observer + ) { + this.client = client; + this.upload = upload; + this.requestPayloadSize = requestPayloadSize; + this.observer = observer; + } + + @Override + protected void makeAttempt() throws ProtocolException, IOException { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(requestPayloadSize); + uploader.setRequestPayloadSize(requestPayloadSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadUrl = uploader.getUploadURL().toString(); + } + + @Override + protected boolean shouldRetry(ProtocolException exception, int retryAttempt) { + return observer.shouldRetry(retryAttempt); + } + + @Override + protected boolean shouldRetry(IOException exception, int retryAttempt) { + return observer.shouldRetry(retryAttempt); + } + + @Override + protected void onRetryScheduled(int retryAttempt, int delayMillis) { + observer.retryScheduled(retryAttempt, delayMillis); + } + + void resetAfterProgress() { + resetRetryAttempts(); + } + + String uploadUrl() { + if (uploadUrl == null) { + throw new IllegalStateException("retry state transition upload did not expose a URL"); + } + + return uploadUrl; + } + } + + private static final class RetryStateObserver { + private final List decisions; + private final JSONArray events; + private final int[] retryDelays; + private int decisionIndex; + + RetryStateObserver(List decisions, int[] retryDelays) { + this.decisions = decisions; + this.events = new JSONArray(); + this.retryDelays = retryDelays; + } + + boolean shouldRetry(int retryAttempt) { + if (decisionIndex >= decisions.size()) { + throw new IllegalStateException( + "retry state transition received unexpected retry decision " + + decisionIndex + ); + } + + final RetryDecision decision = decisions.get(decisionIndex); + if (retryAttempt != decision.retryAttempt) { + throw new IllegalStateException( + "retry state transition expected retry attempt " + + decision.retryAttempt + + ", got " + + retryAttempt + ); + } + + events.put(new JSONObject() + .put("decision", decision.decision) + .put("kind", "should-retry") + .put("retryAttempt", retryAttempt)); + decisionIndex += 1; + return decision.decision; + } + + void retryScheduled(int retryAttempt, int delayMillis) { + if (retryAttempt < 0 || retryAttempt >= retryDelays.length) { + throw new IllegalStateException( + "retry state transition retry attempt " + + retryAttempt + + " has no retry delay" + ); + } + if (delayMillis != retryDelays[retryAttempt]) { + throw new IllegalStateException( + "retry state transition expected retry delay " + + retryDelays[retryAttempt] + + ", got " + + delayMillis + ); + } + + events.put(new JSONObject() + .put("delay", delayMillis) + .put("kind", "retry-schedule")); + } + + void assertComplete() { + if (decisionIndex == decisions.size()) { + return; + } + + throw new IllegalStateException( + "retry state transition expected " + + decisions.size() + + " retry decision(s), got " + + decisionIndex + ); + } + + JSONArray events() { + return events; + } + } + + private static final class RetryDecision { + final boolean decision; + final int retryAttempt; + + RetryDecision(boolean decision, int retryAttempt) { + this.decision = decision; + this.retryAttempt = retryAttempt; + } + } + + private Api2DevdockTusRetryStateTransitions() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusExecutor.java b/src/main/java/io/tus/java/client/TusExecutor.java index 19f7b902..4589d2af 100644 --- a/src/main/java/io/tus/java/client/TusExecutor.java +++ b/src/main/java/io/tus/java/client/TusExecutor.java @@ -32,6 +32,7 @@ */ public abstract class TusExecutor { private int[] delays = new int[]{500, 1000, 2000, 3000}; + private int retryAttempt; /** * Set the delays at which TusExecutor will issue a retry if {@link #makeAttempt()} throws an @@ -77,10 +78,8 @@ public int[] getDelays() { * @throws IOException */ public boolean makeAttempts() throws ProtocolException, IOException { - int attempt = -1; + retryAttempt = 0; while (true) { - attempt++; - try { makeAttempt(); // Returning true is the signal that the makeAttempt() function exited without @@ -88,26 +87,34 @@ public boolean makeAttempts() throws ProtocolException, IOException { return true; } catch (ProtocolException e) { // Do not attempt a retry, if the Exception suggests so. - if (!e.shouldRetry()) { + if (!shouldRetry(e, retryAttempt)) { throw e; } - if (attempt >= delays.length) { + if (retryAttempt >= delays.length) { // We exceeds the number of maximum retries. In this case the latest exception // is thrown. throw e; } } catch (IOException e) { - if (attempt >= delays.length) { + if (!shouldRetry(e, retryAttempt)) { + throw e; + } + + if (retryAttempt >= delays.length) { // We exceeds the number of maximum retries. In this case the latest exception // is thrown. throw e; } } + int delay = delays[retryAttempt]; + onRetryScheduled(retryAttempt, delay); + retryAttempt++; + try { // Sleep for the specified delay before attempting the next retry. - Thread.sleep(delays[attempt]); + Thread.sleep(delay); } catch (InterruptedException e) { // If we get interrupted while waiting for the next retry, the user has cancelled // the upload willingly and we return false as a signal. @@ -116,6 +123,44 @@ public boolean makeAttempts() throws ProtocolException, IOException { } } + /** + * Reset the retry attempt counter. Call this from {@link #makeAttempt()} after observing accepted + * server-side upload progress, so later retry decisions start from the first retry delay again. + */ + protected void resetRetryAttempts() { + retryAttempt = 0; + } + + /** + * Decide whether a protocol failure should be retried. + * + * @param exception Protocol failure from the current attempt. + * @param retryAttempt Zero-based retry attempt since the last successful progress reset. + * @return {@code true} if another attempt should be scheduled. + */ + protected boolean shouldRetry(ProtocolException exception, int retryAttempt) { + return exception.shouldRetry(); + } + + /** + * Decide whether an I/O failure should be retried. + * + * @param exception I/O failure from the current attempt. + * @param retryAttempt Zero-based retry attempt since the last successful progress reset. + * @return {@code true} if another attempt should be scheduled. + */ + protected boolean shouldRetry(IOException exception, int retryAttempt) { + return true; + } + + /** + * Observe a scheduled retry. + * + * @param retryAttempt Zero-based retry attempt since the last successful progress reset. + * @param delayMillis Delay in milliseconds before the next attempt. + */ + protected void onRetryScheduled(int retryAttempt, int delayMillis) { } + /** * This method must be implemented by the specific caller. It will be invoked once or multiple * times by the {@link #makeAttempts()} method. diff --git a/src/test/java/io/tus/java/client/TestTusExecutor.java b/src/test/java/io/tus/java/client/TestTusExecutor.java index e773e566..e114e2d4 100644 --- a/src/test/java/io/tus/java/client/TestTusExecutor.java +++ b/src/test/java/io/tus/java/client/TestTusExecutor.java @@ -6,6 +6,8 @@ import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; +import java.util.List; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -76,6 +78,74 @@ public void testMakeAttempts() throws Exception { assertEquals(exec.getCalls(), 1); } + /** + * Tests if retry decisions and schedules can be observed, and progress can reset retry state. + * @throws Exception + */ + @Test + public void testRetryHooksAndReset() throws Exception { + final List events = new ArrayList(); + CountingExecutor exec = new CountingExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + super.makeAttempt(); + if (getCalls() == 1) { + throw new IOException(); + } + if (getCalls() == 2) { + resetRetryAttempts(); + throw new IOException(); + } + } + + @Override + protected boolean shouldRetry(IOException exception, int retryAttempt) { + events.add("should-retry:" + retryAttempt); + return true; + } + + @Override + protected void onRetryScheduled(int retryAttempt, int delayMillis) { + events.add("retry-schedule:" + retryAttempt + ":" + delayMillis); + } + }; + + exec.setDelays(new int[]{1, 2, 3}); + assertTrue(exec.makeAttempts()); + assertEquals(exec.getCalls(), 3); + assertEquals(events.size(), 4); + assertEquals(events.get(0), "should-retry:0"); + assertEquals(events.get(1), "retry-schedule:0:1"); + assertEquals(events.get(2), "should-retry:0"); + assertEquals(events.get(3), "retry-schedule:0:1"); + } + + /** + * Tests if retry hooks can stop retrying I/O failures. + * @throws Exception + */ + @Test(expected = IOException.class) + public void testRetryHookCanStopIoRetry() throws Exception { + CountingExecutor exec = new CountingExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + super.makeAttempt(); + throw new IOException(); + } + + @Override + protected boolean shouldRetry(IOException exception, int retryAttempt) { + return false; + } + }; + + try { + exec.makeAttempts(); + } finally { + assertEquals(exec.getCalls(), 1); + } + } + /** * Tests if every attempt can throw an IOException. From 5a810f08a017615ab5cc6659c2fe3e81c33659e3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 05:48:30 +0200 Subject: [PATCH 88/96] Add detailed error proof --- example/build.gradle | 6 + .../example/Api2DevdockTusDetailedError.java | 227 ++++++++++++++++++ .../java/io/tus/java/client/TusClient.java | 37 ++- .../io/tus/java/client/TusDetailedError.java | 57 +++++ .../io/tus/java/client/TusDetailedErrors.java | 131 ++++++++++ .../java/io/tus/java/client/TusProtocol.java | 22 ++ .../tus/java/client/TusRequestException.java | 51 ++++ .../tus/java/client/TusRequestSnapshot.java | 33 +++ .../tus/java/client/TusResponseException.java | 61 +++++ .../io/tus/java/client/TestTusClient.java | 114 +++++++++ 10 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java create mode 100644 src/main/java/io/tus/java/client/TusDetailedError.java create mode 100644 src/main/java/io/tus/java/client/TusDetailedErrors.java create mode 100644 src/main/java/io/tus/java/client/TusRequestException.java create mode 100644 src/main/java/io/tus/java/client/TusRequestSnapshot.java create mode 100644 src/main/java/io/tus/java/client/TusResponseException.java diff --git a/example/build.gradle b/example/build.gradle index 189cc790..27f1264f 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -48,6 +48,12 @@ tasks.register('api2DevdockTusRetryStateTransitions', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusDetailedError', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusDetailedError' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java new file mode 100644 index 00000000..fa2578e3 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java @@ -0,0 +1,227 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusDetailedError; +import io.tus.java.client.TusUpload; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Map; + +public final class Api2DevdockTusDetailedError { + public static void main(String[] args) throws Exception { + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadExpectingDetailedError( + Api2DevdockScenario.conformanceScenario(scenario) + ); + + Api2DevdockScenario.writeResult(result); + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed detailed error " + + result.getString("errorMessage") + ); + } + + private static JSONObject uploadExpectingDetailedError(JSONObject conformanceScenario) + throws Exception { + final JSONObject requestPlan = conformanceScenario + .getJSONArray("requests") + .getJSONObject(0); + if (!requestPlan.isNull("errorMessage")) { + return uploadExpectingRequestError(conformanceScenario, requestPlan); + } + + return uploadExpectingResponseError(conformanceScenario); + } + + private static JSONObject uploadExpectingResponseError(JSONObject conformanceScenario) + throws Exception { + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption(conformanceScenario, "endpointUrl") + ); + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final TusClient client = clientFor(conformanceScenario); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final Throwable error = createUploadExpectingError(client, conformanceScenario); + conformanceServer.assertExhausted(); + + final JSONObject serverResult = conformanceServer.result(); + return detailedResult( + error, + canonicalize(serverResult, conformanceServer), + conformanceServer + ); + } + } + + private static JSONObject uploadExpectingRequestError( + JSONObject conformanceScenario, + JSONObject requestPlan + ) throws Exception { + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption(conformanceScenario, "endpointUrl") + ); + final FailingTusClient client = new FailingTusClient(requestPlan.getString("errorMessage")); + configureClient(client, conformanceScenario); + client.setUploadCreationURL(endpointUrl); + + final Throwable error = createUploadExpectingError(client, conformanceScenario); + final JSONObject requestResult = new JSONObject() + .put("requestMethods", new JSONArray().put(requestPlan.getString("effectiveMethod"))) + .put("requestUrls", new JSONArray().put(requestPlan.getString("expectedUrl"))); + + return detailedResult(error, requestResult, null); + } + + private static TusClient clientFor(JSONObject conformanceScenario) { + final TusClient client = new TusClient(); + configureClient(client, conformanceScenario); + return client; + } + + private static void configureClient(TusClient client, JSONObject conformanceScenario) { + final Map headers = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "headers" + ); + client.setHeaders(headers); + } + + private static Throwable createUploadExpectingError( + TusClient client, + JSONObject conformanceScenario + ) throws Exception { + final TusUpload upload = uploadFor(conformanceScenario); + try { + client.createUpload(upload); + } catch (IOException | ProtocolException error) { + return error; + } + + throw new IllegalStateException("detailed error scenario unexpectedly created an upload"); + } + + private static TusUpload uploadFor(JSONObject conformanceScenario) { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setMetadata(metadata); + upload.setSize(content.length); + return upload; + } + + private static JSONObject detailedResult( + Throwable error, + JSONObject requestResult, + Api2DevdockTusConformanceServer conformanceServer + ) { + final JSONObject result = new JSONObject() + .put("errorCaught", true) + .put("errorIsDetailed", error instanceof TusDetailedError) + .put("errorMessage", canonicalValue(error.getMessage(), conformanceServer)) + .put("requestCount", requestResult.getJSONArray("requestMethods").length()) + .put("requestMethods", requestResult.getJSONArray("requestMethods")) + .put("requestUrls", requestResult.getJSONArray("requestUrls")); + + if (!(error instanceof TusDetailedError)) { + return result; + } + + final TusDetailedError detailedError = (TusDetailedError) error; + result.put("causingErrorPresent", detailedError.getCausingError() != null); + if (detailedError.getCausingError() != null) { + result.put("causingErrorMessage", detailedError.getCausingError().getMessage()); + } + result.put("originalRequestMethod", detailedError.getOriginalRequestMethod()); + result.put("originalRequestRequestId", detailedError.getOriginalRequestId()); + result.put( + "originalRequestUrl", + canonicalValue(detailedError.getOriginalRequestURL().toString(), conformanceServer) + ); + result.put("originalResponsePresent", detailedError.hasOriginalResponse()); + if (detailedError.hasOriginalResponse()) { + result.put("originalResponseBody", detailedError.getOriginalResponseBody()); + result.put("originalResponseStatus", detailedError.getOriginalResponseStatus()); + } + + return result; + } + + private static JSONObject canonicalize( + JSONObject requestResult, + Api2DevdockTusConformanceServer conformanceServer + ) { + final JSONArray requestUrls = requestResult.getJSONArray("requestUrls"); + final JSONArray canonicalUrls = new JSONArray(); + for (int index = 0; index < requestUrls.length(); index++) { + canonicalUrls.put(conformanceServer.canonicalUrl(requestUrls.getString(index))); + } + + return new JSONObject(requestResult.toString()).put("requestUrls", canonicalUrls); + } + + private static String canonicalValue( + String value, + Api2DevdockTusConformanceServer conformanceServer + ) { + if (conformanceServer == null) { + return value; + } + + return conformanceServer.canonicalUrl(value); + } + + private static final class FailingTusClient extends TusClient { + private final String errorMessage; + + FailingTusClient(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + protected HttpURLConnection openConnection(URL uploadUrl) { + return new FailingHttpURLConnection(uploadUrl, errorMessage); + } + } + + private static final class FailingHttpURLConnection extends HttpURLConnection { + private final String errorMessage; + + FailingHttpURLConnection(URL url, String errorMessage) { + super(url); + this.errorMessage = errorMessage; + } + + @Override + public void connect() throws IOException { + throw new IOException(errorMessage); + } + + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + } + + private Api2DevdockTusDetailedError() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index be077f22..d6fd3cec 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -300,17 +300,38 @@ private TusUploader createUpload( } runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); - if (bytesToUpload > 0) { - writeUploadCreationData(connection, upload, bytesToUpload); - } else { - connection.connect(); + TusRequestSnapshot requestSnapshot = TusRequestSnapshot.fromConnection(connection); + try { + if (bytesToUpload > 0) { + writeUploadCreationData(connection, upload, bytesToUpload); + } else { + connection.connect(); + } + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); } - int responseCode = connection.getResponseCode(); + int responseCode; + try { + responseCode = connection.getResponseCode(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { - throw new ProtocolException( - "unexpected status code (" + responseCode + ") while creating upload", connection); + throw TusDetailedErrors.responseException( + TusProtocol.DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + requestSnapshot, + connection + ); } String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); @@ -445,7 +466,7 @@ public HttpURLConnection terminateUpload(@NotNull URL uploadURL) } @NotNull - private HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOException { + protected HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOException { if (proxy != null) { return (HttpURLConnection) uploadURL.openConnection(proxy); } diff --git a/src/main/java/io/tus/java/client/TusDetailedError.java b/src/main/java/io/tus/java/client/TusDetailedError.java new file mode 100644 index 00000000..7ee1b006 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusDetailedError.java @@ -0,0 +1,57 @@ +package io.tus.java.client; + +import java.net.URL; + +/** + * Exposes request and response context for TUS failures. + */ +public interface TusDetailedError { + /** + * Returns the lower-level cause for request failures, or null for response failures. + * + * @return Causing error, or null. + */ + Throwable getCausingError(); + + /** + * Returns the method of the original TUS request. + * + * @return HTTP method. + */ + String getOriginalRequestMethod(); + + /** + * Returns the request ID attached to the original TUS request. + * + * @return Request ID, or the generated missing-value marker. + */ + String getOriginalRequestId(); + + /** + * Returns the URL of the original TUS request. + * + * @return Request URL. + */ + URL getOriginalRequestURL(); + + /** + * Returns whether a response was available. + * + * @return True when a response was captured. + */ + boolean hasOriginalResponse(); + + /** + * Returns the captured response body. + * + * @return Response body, or the generated missing-value marker. + */ + String getOriginalResponseBody(); + + /** + * Returns the captured response status. + * + * @return Response status, or -1 when absent. + */ + int getOriginalResponseStatus(); +} diff --git a/src/main/java/io/tus/java/client/TusDetailedErrors.java b/src/main/java/io/tus/java/client/TusDetailedErrors.java new file mode 100644 index 00000000..5b9a45ae --- /dev/null +++ b/src/main/java/io/tus/java/client/TusDetailedErrors.java @@ -0,0 +1,131 @@ +package io.tus.java.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +final class TusDetailedErrors { + static TusRequestException requestException( + String baseMessage, + TusRequestSnapshot request, + IOException cause + ) { + return new TusRequestException( + detailedErrorMessage(baseMessage, cause, request, -1, null), + request, + cause + ); + } + + static TusResponseException responseException( + String baseMessage, + TusRequestSnapshot request, + HttpURLConnection connection + ) { + final int responseStatus = responseStatus(connection); + final String responseBody = responseBody(connection); + return new TusResponseException( + detailedErrorMessage(baseMessage, null, request, responseStatus, responseBody), + connection, + request, + responseStatus, + responseBody + ); + } + + private static String detailedErrorMessage( + String baseMessage, + IOException cause, + TusRequestSnapshot request, + int responseStatus, + String responseBody + ) { + String message = baseMessage; + if (cause != null) { + final Map causeValues = new LinkedHashMap(); + causeValues.put("message", missingIfEmpty(cause.getMessage())); + final String causeMessage = TusProtocol.formatDetailedErrorMessage( + TusProtocol.DETAILED_ERROR_CAUSE_STRING_TEMPLATE, + causeValues + ); + + final Map causedByValues = new LinkedHashMap(); + causedByValues.put("cause", causeMessage); + message += TusProtocol.formatDetailedErrorMessage( + TusProtocol.DETAILED_ERROR_CAUSED_BY_TEMPLATE, + causedByValues + ); + } + + final Map contextValues = new LinkedHashMap(); + contextValues.put("body", responseBody == null + ? TusProtocol.DETAILED_ERROR_MISSING_VALUE + : responseBody); + contextValues.put("method", request.method); + contextValues.put("requestId", request.requestId); + contextValues.put("status", responseStatus < 0 + ? TusProtocol.DETAILED_ERROR_MISSING_VALUE + : Integer.toString(responseStatus)); + contextValues.put("url", request.url.toString()); + + return message + TusProtocol.formatDetailedErrorMessage( + TusProtocol.DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE, + contextValues + ); + } + + private static String responseBody(HttpURLConnection connection) { + InputStream stream = connection.getErrorStream(); + if (stream == null) { + try { + stream = connection.getInputStream(); + } catch (IOException error) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + } + + try { + final byte[] body = readAllBytes(stream); + if (body.length == 0) { + return TusProtocol.DETAILED_ERROR_EMPTY_RESPONSE_BODY; + } + return new String(body, StandardCharsets.UTF_8); + } catch (IOException error) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + } + + private static int responseStatus(HttpURLConnection connection) { + try { + return connection.getResponseCode(); + } catch (IOException error) { + return -1; + } + } + + private static byte[] readAllBytes(InputStream stream) throws IOException { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int read; + while ((read = stream.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + + return output.toByteArray(); + } + + private static String missingIfEmpty(String value) { + if (value == null || value.length() == 0) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + + return value; + } + + private TusDetailedErrors() { + } +} diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 8feb5275..ad333712 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -17,6 +17,20 @@ */ final class TusProtocol { static final String CREATE_UPLOAD_METHOD = "POST"; + static final String DETAILED_ERROR_CAUSE_STRING_TEMPLATE = + "Error: {message}"; + static final String DETAILED_ERROR_CAUSED_BY_TEMPLATE = + ", caused by {cause}"; + static final String DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED = + "tus: failed to create upload"; + static final String DETAILED_ERROR_EMPTY_RESPONSE_BODY = + ""; + static final String DETAILED_ERROR_MISSING_VALUE = + "n/a"; + static final String DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE = + ", originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})"; + static final String DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE = + "tus: unexpected response while creating upload"; static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); @@ -41,6 +55,14 @@ static boolean isSuccessfulResponseStatus(int responseStatusCode) { && responseStatusCode < SUCCESS_RESPONSE_STATUS_CATEGORY + 100; } + static String formatDetailedErrorMessage(String template, Map values) { + String result = template; + for (Map.Entry entry : values.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + static void prepareRequestHeaders( HttpURLConnection connection, Map customHeaders, diff --git a/src/main/java/io/tus/java/client/TusRequestException.java b/src/main/java/io/tus/java/client/TusRequestException.java new file mode 100644 index 00000000..08c0ecd5 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusRequestException.java @@ -0,0 +1,51 @@ +package io.tus.java.client; + +import java.io.IOException; +import java.net.URL; + +/** + * An {@link IOException} with TUS request context. + */ +public class TusRequestException extends IOException implements TusDetailedError { + private final TusRequestSnapshot snapshot; + + TusRequestException(String message, TusRequestSnapshot snapshot, IOException cause) { + super(message, cause); + this.snapshot = snapshot; + } + + @Override + public Throwable getCausingError() { + return getCause(); + } + + @Override + public String getOriginalRequestMethod() { + return snapshot.method; + } + + @Override + public String getOriginalRequestId() { + return snapshot.requestId; + } + + @Override + public URL getOriginalRequestURL() { + return snapshot.url; + } + + @Override + public boolean hasOriginalResponse() { + return false; + } + + @Override + public String getOriginalResponseBody() { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + + @Override + public int getOriginalResponseStatus() { + return -1; + } +} diff --git a/src/main/java/io/tus/java/client/TusRequestSnapshot.java b/src/main/java/io/tus/java/client/TusRequestSnapshot.java new file mode 100644 index 00000000..0a4be9bd --- /dev/null +++ b/src/main/java/io/tus/java/client/TusRequestSnapshot.java @@ -0,0 +1,33 @@ +package io.tus.java.client; + +import java.net.HttpURLConnection; +import java.net.URL; + +final class TusRequestSnapshot { + final String method; + final String requestId; + final URL url; + + private TusRequestSnapshot(String method, URL url, String requestId) { + this.method = method; + this.requestId = requestId; + this.url = url; + } + + static TusRequestSnapshot fromConnection(HttpURLConnection connection) { + return new TusRequestSnapshot( + connection.getRequestMethod(), + connection.getURL(), + requestId(connection) + ); + } + + private static String requestId(HttpURLConnection connection) { + final String requestId = connection.getRequestProperty(TusProtocol.REQUEST_ID_HEADER_NAME); + if (requestId == null || requestId.length() == 0) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + + return requestId; + } +} diff --git a/src/main/java/io/tus/java/client/TusResponseException.java b/src/main/java/io/tus/java/client/TusResponseException.java new file mode 100644 index 00000000..ad5d41ef --- /dev/null +++ b/src/main/java/io/tus/java/client/TusResponseException.java @@ -0,0 +1,61 @@ +package io.tus.java.client; + +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * A {@link ProtocolException} with TUS request and response context. + */ +public class TusResponseException extends ProtocolException implements TusDetailedError { + private final String responseBody; + private final int responseStatus; + private final TusRequestSnapshot snapshot; + + TusResponseException( + String message, + HttpURLConnection connection, + TusRequestSnapshot snapshot, + int responseStatus, + String responseBody + ) { + super(message, connection); + this.responseBody = responseBody; + this.responseStatus = responseStatus; + this.snapshot = snapshot; + } + + @Override + public Throwable getCausingError() { + return null; + } + + @Override + public String getOriginalRequestMethod() { + return snapshot.method; + } + + @Override + public String getOriginalRequestId() { + return snapshot.requestId; + } + + @Override + public URL getOriginalRequestURL() { + return snapshot.url; + } + + @Override + public boolean hasOriginalResponse() { + return true; + } + + @Override + public String getOriginalResponseBody() { + return responseBody; + } + + @Override + public int getOriginalResponseStatus() { + return responseStatus; + } +} diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index 15347bf1..a8598cb8 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -300,6 +300,94 @@ public void testCreateUploadWithMissingLocationHeader() throws Exception { } } + /** + * Tests if create-upload response failures expose detailed request and response context. + * @throws Exception if unreachable code has been reached. + */ + @Test + public void testCreateUploadWithDetailedResponseError() throws Exception { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Upload-Length", "10") + .withHeader("X-Request-ID", "contract-request-id"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(500) + .withBody("server_error"))); + + Map headers = new LinkedHashMap(); + headers.put("X-Request-ID", "contract-request-id"); + + TusClient client = new TusClient(); + client.setHeaders(headers); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + try { + client.createUpload(upload); + throw new Exception("unreachable code reached"); + } catch (TusResponseException error) { + assertEquals( + "tus: unexpected response while creating upload, originated from request " + + "(method: POST, url: " + + mockServerURL + + ", response code: 500, response text: server_error, request id: " + + "contract-request-id)", + error.getMessage() + ); + assertNull(error.getCausingError()); + assertEquals("POST", error.getOriginalRequestMethod()); + assertEquals("contract-request-id", error.getOriginalRequestId()); + assertEquals(mockServerURL, error.getOriginalRequestURL()); + assertTrue(error.hasOriginalResponse()); + assertEquals("server_error", error.getOriginalResponseBody()); + assertEquals(500, error.getOriginalResponseStatus()); + } + } + + /** + * Tests if create-upload request failures expose detailed request context. + * @throws Exception if unreachable code has been reached. + */ + @Test + public void testCreateUploadWithDetailedRequestError() throws Exception { + Map headers = new LinkedHashMap(); + headers.put("X-Request-ID", "contract-request-id"); + + TusClient client = new TusClient() { + @Override + protected HttpURLConnection openConnection(URL uploadURL) { + return new FailingHttpURLConnection(uploadURL, "socket down"); + } + }; + client.setHeaders(headers); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + try { + client.createUpload(upload); + throw new Exception("unreachable code reached"); + } catch (TusRequestException error) { + assertEquals( + "tus: failed to create upload, caused by Error: socket down, " + + "originated from request (method: POST, url: " + + mockServerURL + + ", response code: n/a, response text: n/a, request id: " + + "contract-request-id)", + error.getMessage() + ); + assertEquals("socket down", error.getCausingError().getMessage()); + assertEquals("POST", error.getOriginalRequestMethod()); + assertEquals("contract-request-id", error.getOriginalRequestId()); + assertEquals(mockServerURL, error.getOriginalRequestURL()); + assertFalse(error.hasOriginalResponse()); + assertEquals(TusProtocol.DETAILED_ERROR_MISSING_VALUE, error.getOriginalResponseBody()); + assertEquals(-1, error.getOriginalResponseStatus()); + } + } + /** * Tests if uploads with relative upload destinations are working. * @throws Exception @@ -723,4 +811,30 @@ public void testRemoveFingerprintOnSuccessEnabled() throws IOException, Protocol assertNull(store.get("fingerprint")); } + + /** + * A mocked HttpURLConnection which fails while connecting. + */ + private static final class FailingHttpURLConnection extends HttpURLConnection { + private final String errorMessage; + + FailingHttpURLConnection(URL url, String errorMessage) { + super(url); + this.errorMessage = errorMessage; + } + + @Override + public void connect() throws IOException { + throw new IOException(errorMessage); + } + + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + } } From ed7af783fd98a955c266d22eba47142c168490d2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 06:03:07 +0200 Subject: [PATCH 89/96] Fix detailed error checkstyle --- .../java/example/Api2DevdockTusDetailedError.java | 6 ++++++ src/main/java/io/tus/java/client/TusClient.java | 12 ++++++++++++ .../java/io/tus/java/client/TusRequestException.java | 2 +- .../io/tus/java/client/TusResponseException.java | 2 +- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java index fa2578e3..f9c5bc0b 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java @@ -15,6 +15,12 @@ import java.util.Map; public final class Api2DevdockTusDetailedError { + /** + * Run the API2 devdock detailed-error scenario. + * + * @param args Unused command-line arguments. + * @throws Exception Thrown when the scenario cannot be executed. + */ public static void main(String[] args) throws Exception { final JSONObject scenario = Api2DevdockScenario.loadScenario(); final JSONObject result = uploadExpectingDetailedError( diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index d6fd3cec..209e7f81 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -465,6 +465,18 @@ public HttpURLConnection terminateUpload(@NotNull URL uploadURL) return connection; } + /** + * Opens the HTTP connection used by this client. + * + *

Subclasses may override this method to provide a custom transport for tests or specialized + * environments. Implementations should return a fresh {@link HttpURLConnection} for the given + * URL and must not connect it; callers configure headers, method, and hooks after this method + * returns. + * + * @param uploadURL The request URL. + * @return A new, unconnected HTTP connection. + * @throws IOException Thrown if a connection cannot be opened. + */ @NotNull protected HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOException { if (proxy != null) { diff --git a/src/main/java/io/tus/java/client/TusRequestException.java b/src/main/java/io/tus/java/client/TusRequestException.java index 08c0ecd5..6850d332 100644 --- a/src/main/java/io/tus/java/client/TusRequestException.java +++ b/src/main/java/io/tus/java/client/TusRequestException.java @@ -6,7 +6,7 @@ /** * An {@link IOException} with TUS request context. */ -public class TusRequestException extends IOException implements TusDetailedError { +public final class TusRequestException extends IOException implements TusDetailedError { private final TusRequestSnapshot snapshot; TusRequestException(String message, TusRequestSnapshot snapshot, IOException cause) { diff --git a/src/main/java/io/tus/java/client/TusResponseException.java b/src/main/java/io/tus/java/client/TusResponseException.java index ad5d41ef..8079a832 100644 --- a/src/main/java/io/tus/java/client/TusResponseException.java +++ b/src/main/java/io/tus/java/client/TusResponseException.java @@ -6,7 +6,7 @@ /** * A {@link ProtocolException} with TUS request and response context. */ -public class TusResponseException extends ProtocolException implements TusDetailedError { +public final class TusResponseException extends ProtocolException implements TusDetailedError { private final String responseBody; private final int responseStatus; private final TusRequestSnapshot snapshot; From 35a60e6ce8ad92fb487dec565a69accb484951df Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 07:08:14 +0200 Subject: [PATCH 90/96] Add start option validation proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 58 +++++- .../Api2DevdockTusStartOptionValidation.java | 119 +++++++++++ .../java/io/tus/java/client/TusClient.java | 10 + .../java/io/tus/java/client/TusProtocol.java | 22 ++ .../java/client/TusStartOptionValidator.java | 79 +++++++ .../io/tus/java/client/TusStartOptions.java | 192 ++++++++++++++++++ .../io/tus/java/client/TestTusClient.java | 56 +++++ 8 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java create mode 100644 src/main/java/io/tus/java/client/TusStartOptionValidator.java create mode 100644 src/main/java/io/tus/java/client/TusStartOptions.java diff --git a/example/build.gradle b/example/build.gradle index 27f1264f..c4bb42ac 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -54,6 +54,12 @@ tasks.register('api2DevdockTusDetailedError', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusStartOptionValidation', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusStartOptionValidation' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index 993fac83..50d93c7e 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -124,6 +124,53 @@ static String conformanceInputStringOption(JSONObject conformanceScenario, Strin return scalarString(conformanceInputOption(conformanceScenario, key)); } + static boolean conformanceInputBooleanOption( + JSONObject conformanceScenario, + String key, + boolean defaultValue + ) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return defaultValue; + } + + if (!(value instanceof Boolean)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not a boolean" + ); + } + + return ((Boolean) value).booleanValue(); + } + + static int conformanceInputIntegerOption( + JSONObject conformanceScenario, + String key, + int defaultValue + ) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return defaultValue; + } + + if (!(value instanceof Number)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not a number" + ); + } + + return ((Number) value).intValue(); + } + + static String conformanceInputStringOptionOrNull(JSONObject conformanceScenario, String key) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return null; + } + + return scalarString(value); + } + static byte[] scenarioBytes(JSONObject uploadConfig) { final JSONObject source = uploadConfig.getJSONObject("source"); final String kind = source.getString("kind"); @@ -140,6 +187,15 @@ static byte[] scenarioBytes(JSONObject uploadConfig) { } private static Object conformanceInputOption(JSONObject conformanceScenario, String key) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value != null) { + return value; + } + + throw new IllegalArgumentException("missing conformance input option " + key); + } + + private static Object conformanceInputOptionOrNull(JSONObject conformanceScenario, String key) { final JSONArray entries = conformanceScenario.getJSONArray("inputOptionEntries"); for (int index = 0; index < entries.length(); index++) { final JSONObject entry = entries.getJSONObject(index); @@ -148,7 +204,7 @@ private static Object conformanceInputOption(JSONObject conformanceScenario, Str } } - throw new IllegalArgumentException("missing conformance input option " + key); + return null; } private static JSONObject conformanceInputJSONObjectOption( diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java new file mode 100644 index 00000000..c6eaa671 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java @@ -0,0 +1,119 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusStartOptions; +import io.tus.java.client.TusUpload; + +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; + +public final class Api2DevdockTusStartOptionValidation { + /** + * Run the API2 devdock start-option validation scenario. + * + * @param args Unused command-line arguments. + * @throws Exception Thrown when the scenario cannot be executed. + */ + public static void main(String[] args) throws Exception { + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = validateStartOptions(conformanceScenario); + + Api2DevdockScenario.writeResult(result); + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " rejected " + + conformanceScenario.getJSONObject("completion").getString("reason") + ); + } + + private static JSONObject validateStartOptions(JSONObject conformanceScenario) + throws Exception { + final TusClient client = new TusClient(); + final TusStartOptions options = startOptionsFor(conformanceScenario); + + try { + client.validateStartOptions(options); + } catch (IllegalArgumentException error) { + final String expectedMessage = conformanceScenario + .getJSONObject("completion") + .getString("message"); + if (!expectedMessage.equals(error.getMessage())) { + throw new IllegalStateException( + "expected start option validation error " + + expectedMessage + + ", got " + + error.getMessage() + ); + } + + return new JSONObject() + .put("errorCaught", true) + .put("errorMessage", error.getMessage()) + .put("requestCount", 0); + } + + throw new IllegalStateException("start option validation scenario unexpectedly succeeded"); + } + + private static TusStartOptions startOptionsFor(JSONObject conformanceScenario) + throws Exception { + final TusStartOptions options = new TusStartOptions(); + final String endpointURL = Api2DevdockScenario.conformanceInputStringOptionOrNull( + conformanceScenario, + "endpointUrl" + ); + final String uploadURL = Api2DevdockScenario.conformanceInputStringOptionOrNull( + conformanceScenario, + "uploadUrl" + ); + final int parallelUploads = Api2DevdockScenario.conformanceInputIntegerOption( + conformanceScenario, + "parallelUploads", + 1 + ); + final boolean uploadDataDuringCreation = + Api2DevdockScenario.conformanceInputBooleanOption( + conformanceScenario, + "uploadDataDuringCreation", + false + ); + final boolean uploadLengthDeferred = Api2DevdockScenario.conformanceInputBooleanOption( + conformanceScenario, + "uploadLengthDeferred", + false + ); + + options.setUpload(uploadFor(conformanceScenario, uploadLengthDeferred)); + options.setParallelUploads(parallelUploads); + options.setUploadDataDuringCreation(uploadDataDuringCreation); + options.setUploadLengthDeferred(uploadLengthDeferred); + if (endpointURL != null) { + options.setEndpointURL(new URL(endpointURL)); + } + if (uploadURL != null) { + options.setUploadURL(new URL(uploadURL)); + } + + return options; + } + + private static TusUpload uploadFor( + JSONObject conformanceScenario, + boolean uploadLengthDeferred + ) { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setUploadLengthDeferred(uploadLengthDeferred); + return upload; + } + + private Api2DevdockTusStartOptionValidation() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 209e7f81..33d4e927 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -225,6 +225,16 @@ public int getConnectTimeout() { return connectTimeout; } + /** + * Validate TUS start options before issuing any HTTP request. + * + * @param options The start options to validate. + * @throws IllegalArgumentException Thrown when the options contain a known conflict. + */ + public void validateStartOptions(@NotNull TusStartOptions options) { + TusStartOptionValidator.validate(options); + } + /** * Create a new upload using the Creation extension. Before calling this function, an "upload * creation URL" must be defined using {@link #setUploadCreationURL(URL)} or else this diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index ad333712..11318a44 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -32,12 +32,34 @@ final class TusProtocol { static final String DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE = "tus: unexpected response while creating upload"; static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; + static final int DEFAULT_PARALLEL_UPLOADS = 1; static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); static final String LOCATION_HEADER_NAME = "Location"; static final String METADATA_HEADER_NAME = "Upload-Metadata"; + static final int MINIMUM_PARALLEL_UPLOADS = 2; static final String OFFSET_DISCOVERY_METHOD = "HEAD"; static final String REQUEST_ID_HEADER_NAME = "X-Request-ID"; + static final String START_OPTION_VALIDATION_MISSING_ENDPOINT_OR_UPLOAD_URL = + "tus: neither an endpoint or an upload URL is provided"; + static final String START_OPTION_VALIDATION_MISSING_INPUT = + "tus: no file or stream to upload provided"; + static final String START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_LENGTH_MISMATCH = + "tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`"; + static final String START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_WITHOUT_PARALLEL_UPLOADS = + "tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_DEFERRED_LENGTH = + "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_DATA_DURING_CREATION = + "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_SIZE = + "tus: cannot use the `uploadSize` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_URL = + "tus: cannot use the `uploadUrl` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_RETRY_DELAYS_NOT_ARRAY = + "tus: the `retryDelays` option must either be an array or null"; + static final String START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX = + "tus: unsupported protocol "; static final int SUCCESS_RESPONSE_STATUS_CATEGORY = 200; static final String TERMINATE_UPLOAD_METHOD = "DELETE"; static final String UPLOAD_BODY_CONTENT_TYPE = "application/offset+octet-stream"; diff --git a/src/main/java/io/tus/java/client/TusStartOptionValidator.java b/src/main/java/io/tus/java/client/TusStartOptionValidator.java new file mode 100644 index 00000000..75277f6f --- /dev/null +++ b/src/main/java/io/tus/java/client/TusStartOptionValidator.java @@ -0,0 +1,79 @@ +package io.tus.java.client; + +final class TusStartOptionValidator { + static void validate(TusStartOptions options) { + if (missingInput(options)) { + throw new IllegalArgumentException(TusProtocol.START_OPTION_VALIDATION_MISSING_INPUT); + } + + if (options.getEndpointURL() == null && options.getUploadURL() == null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_MISSING_ENDPOINT_OR_UPLOAD_URL + ); + } + + if (!TusProtocol.DEFAULT_PROTOCOL_VERSION.equals(options.getProtocol())) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + + options.getProtocol() + ); + } + + final boolean parallelUploadsEnabled = + options.getParallelUploads() >= TusProtocol.MINIMUM_PARALLEL_UPLOADS; + final boolean parallelBoundariesSet = options.getParallelUploadBoundariesCount() >= 0; + + if (parallelBoundariesSet && !parallelUploadsEnabled) { + throw new IllegalArgumentException( + TusProtocol + .START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_WITHOUT_PARALLEL_UPLOADS + ); + } + + if (!parallelUploadsEnabled) { + return; + } + + if (options.getUploadURL() != null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_URL + ); + } + + if (options.getUploadSize() != null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_SIZE + ); + } + + if (options.isUploadLengthDeferred() || options.getUpload().isUploadLengthDeferred()) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_DEFERRED_LENGTH + ); + } + + if (options.isUploadDataDuringCreation()) { + throw new IllegalArgumentException( + TusProtocol + .START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_DATA_DURING_CREATION + ); + } + + if (parallelBoundariesSet + && options.getParallelUploadBoundariesCount() != options.getParallelUploads()) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_LENGTH_MISMATCH + ); + } + } + + private static boolean missingInput(TusStartOptions options) { + return options == null + || options.getUpload() == null + || options.getUpload().getInputStream() == null; + } + + private TusStartOptionValidator() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusStartOptions.java b/src/main/java/io/tus/java/client/TusStartOptions.java new file mode 100644 index 00000000..a5434dd1 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusStartOptions.java @@ -0,0 +1,192 @@ +package io.tus.java.client; + +import org.jetbrains.annotations.Nullable; + +import java.net.URL; + +/** + * Options that can be validated before starting a TUS upload. + */ +public final class TusStartOptions { + private URL endpointURL; + private int parallelUploadBoundariesCount = -1; + private int parallelUploads = TusProtocol.DEFAULT_PARALLEL_UPLOADS; + private String protocol = TusProtocol.DEFAULT_PROTOCOL_VERSION; + private TusUpload upload; + private boolean uploadDataDuringCreation; + private boolean uploadLengthDeferred; + private Long uploadSize; + private URL uploadURL; + + /** + * Create default TUS start options. + */ + public TusStartOptions() { + } + + /** + * Return the endpoint URL used to create a new upload. + * + * @return The endpoint URL, or null. + */ + @Nullable + public URL getEndpointURL() { + return endpointURL; + } + + /** + * Set the endpoint URL used to create a new upload. + * + * @param endpointURL The endpoint URL, or null. + */ + public void setEndpointURL(@Nullable URL endpointURL) { + this.endpointURL = endpointURL; + } + + /** + * Return the number of explicit parallel upload boundaries. + * + * @return The boundary count, or -1 when unset. + */ + public int getParallelUploadBoundariesCount() { + return parallelUploadBoundariesCount; + } + + /** + * Set the number of explicit parallel upload boundaries. + * + * @param parallelUploadBoundariesCount The boundary count, or -1 when unset. + */ + public void setParallelUploadBoundariesCount(int parallelUploadBoundariesCount) { + this.parallelUploadBoundariesCount = parallelUploadBoundariesCount; + } + + /** + * Return the configured parallel upload count. + * + * @return The parallel upload count. + */ + public int getParallelUploads() { + return parallelUploads; + } + + /** + * Set the configured parallel upload count. + * + * @param parallelUploads The parallel upload count. + */ + public void setParallelUploads(int parallelUploads) { + this.parallelUploads = parallelUploads; + } + + /** + * Return the requested TUS protocol version. + * + * @return The protocol version. + */ + public String getProtocol() { + return protocol; + } + + /** + * Set the requested TUS protocol version. + * + * @param protocol The protocol version. + */ + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + /** + * Return the upload source. + * + * @return The upload source, or null. + */ + @Nullable + public TusUpload getUpload() { + return upload; + } + + /** + * Set the upload source. + * + * @param upload The upload source, or null. + */ + public void setUpload(@Nullable TusUpload upload) { + this.upload = upload; + } + + /** + * Return whether bytes should be sent during creation. + * + * @return True when creation with upload is requested. + */ + public boolean isUploadDataDuringCreation() { + return uploadDataDuringCreation; + } + + /** + * Set whether bytes should be sent during creation. + * + * @param uploadDataDuringCreation True to request creation with upload. + */ + public void setUploadDataDuringCreation(boolean uploadDataDuringCreation) { + this.uploadDataDuringCreation = uploadDataDuringCreation; + } + + /** + * Return whether upload length declaration should be deferred. + * + * @return True when upload length should be deferred. + */ + public boolean isUploadLengthDeferred() { + return uploadLengthDeferred; + } + + /** + * Set whether upload length declaration should be deferred. + * + * @param uploadLengthDeferred True to defer the upload length declaration. + */ + public void setUploadLengthDeferred(boolean uploadLengthDeferred) { + this.uploadLengthDeferred = uploadLengthDeferred; + } + + /** + * Return the configured upload size. + * + * @return The configured upload size, or null. + */ + @Nullable + public Long getUploadSize() { + return uploadSize; + } + + /** + * Set the configured upload size. + * + * @param uploadSize The configured upload size, or null. + */ + public void setUploadSize(@Nullable Long uploadSize) { + this.uploadSize = uploadSize; + } + + /** + * Return the existing upload URL to resume. + * + * @return The upload URL, or null. + */ + @Nullable + public URL getUploadURL() { + return uploadURL; + } + + /** + * Set the existing upload URL to resume. + * + * @param uploadURL The upload URL, or null. + */ + public void setUploadURL(@Nullable URL uploadURL) { + this.uploadURL = uploadURL; + } +} diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index a8598cb8..c2f5da90 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -388,6 +388,51 @@ protected HttpURLConnection openConnection(URL uploadURL) { } } + /** + * Tests if start option validation rejects parallel uploads with an upload URL. + * @throws MalformedURLException if the provided URL is malformed. + */ + @Test + public void testValidateStartOptionsRejectsParallelUploadsWithUploadURL() + throws MalformedURLException { + TusStartOptions options = validStartOptions(); + options.setParallelUploads(TusProtocol.MINIMUM_PARALLEL_UPLOADS); + options.setUploadURL(new URL("https://tus.io/uploads/start-validation-upload-url")); + + try { + new TusClient().validateStartOptions(options); + fail("start option validation unexpectedly succeeded"); + } catch (IllegalArgumentException error) { + assertEquals( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_URL, + error.getMessage() + ); + } + } + + /** + * Tests if start option validation rejects parallel uploads with creation data. + * @throws MalformedURLException if the provided URL is malformed. + */ + @Test + public void testValidateStartOptionsRejectsParallelUploadsWithUploadDataDuringCreation() + throws MalformedURLException { + TusStartOptions options = validStartOptions(); + options.setParallelUploads(TusProtocol.MINIMUM_PARALLEL_UPLOADS); + options.setUploadDataDuringCreation(true); + + try { + new TusClient().validateStartOptions(options); + fail("start option validation unexpectedly succeeded"); + } catch (IllegalArgumentException error) { + assertEquals( + TusProtocol + .START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_DATA_DURING_CREATION, + error.getMessage() + ); + } + } + /** * Tests if uploads with relative upload destinations are working. * @throws Exception @@ -837,4 +882,15 @@ public boolean usingProxy() { return false; } } + + private static TusStartOptions validStartOptions() throws MalformedURLException { + TusUpload upload = new TusUpload(); + upload.setSize(11); + upload.setInputStream(new ByteArrayInputStream(new byte[11])); + + TusStartOptions options = new TusStartOptions(); + options.setEndpointURL(new URL("https://tus.io/uploads")); + options.setUpload(upload); + return options; + } } From c95678af8cb9e97b1ce620388ae98f69d51a8cb2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 09:03:51 +0200 Subject: [PATCH 91/96] Add Java TUS abort upload proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 21 ++ .../example/Api2DevdockTusAbortUpload.java | 205 +++++++++++++++++ .../Api2DevdockTusConformanceServer.java | 66 ++++++ .../java/io/tus/java/client/TusClient.java | 213 ++++++++++++------ .../java/io/tus/java/client/TusUploader.java | 88 +++++++- .../io/tus/java/client/TestTusClient.java | 36 +++ 7 files changed, 554 insertions(+), 81 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java diff --git a/example/build.gradle b/example/build.gradle index c4bb42ac..c4bbb313 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -54,6 +54,12 @@ tasks.register('api2DevdockTusDetailedError', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusAbortUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusAbortUpload' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusStartOptionValidation', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusStartOptionValidation' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index 50d93c7e..cc054aac 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -112,6 +112,27 @@ static Map conformanceInputStringMapOption( String key ) { final JSONObject values = conformanceInputJSONObjectOption(conformanceScenario, key); + return scalarStringMap(values); + } + + static Map conformanceInputStringMapOptionOrEmpty( + JSONObject conformanceScenario, + String key + ) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return new LinkedHashMap(); + } + if (!(value instanceof JSONObject)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not an object" + ); + } + + return scalarStringMap((JSONObject) value); + } + + private static Map scalarStringMap(JSONObject values) { final Map result = new LinkedHashMap(); for (String name : values.keySet()) { result.put(name, scalarString(values.get(name))); diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java new file mode 100644 index 00000000..cc33c278 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java @@ -0,0 +1,205 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public final class Api2DevdockTusAbortUpload { + /** + * Run the API2 devdock TUS abort-upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadAndAbort(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " aborted the upload" + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadAndAbort(JSONObject scenario) throws Exception { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final Map headers = Api2DevdockScenario.conformanceInputStringMapOptionOrEmpty( + conformanceScenario, + "headers" + ); + final boolean terminateUploadOnAbort = conformanceScenario + .getJSONObject("runtimeSetup") + .getJSONObject("abort") + .getBoolean("terminateUpload"); + final String fingerprint = runtimeFingerprint(conformanceScenario, scenario); + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + + final TusClient client = new TusClient(); + final TusURLMemoryStore urlStore = new TusURLMemoryStore(); + final List events = new ArrayList(); + final AtomicReference activeUploader = new AtomicReference(); + final AtomicBoolean aborted = new AtomicBoolean(false); + final AtomicBoolean successCalled = new AtomicBoolean(false); + final AtomicReference uploadError = new AtomicReference(); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer( + conformanceScenario, + endpointOrigin, + new Api2DevdockTusConformanceServer.RequestAbortHandler() { + @Override + public void abortRequest( + Api2DevdockTusConformanceServer.RequestAbortContext context + ) throws Exception { + aborted.set(true); + events.add(new JSONObject() + .put("kind", "request-abort") + .put("method", context.method()) + .put("requestIndex", context.requestIndex()) + .put("url", context.url())); + + final TusUploader uploader = activeUploader.get(); + if (uploader == null) { + if (terminateUploadOnAbort) { + throw new IllegalStateException( + "abort scenario requested termination " + + "before uploader was available" + ); + } + client.abortUpload(); + return; + } + + client.abortUpload(uploader, false); + } + })) { + client.setUploadCreationURL(conformanceServer.endpointUrl()); + client.enableResuming(urlStore); + client.setHeaders(headers); + + final TusUpload upload = uploadFor(content, fingerprint, metadata); + final Thread uploadThread = new Thread(new Runnable() { + @Override + public void run() { + try { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + activeUploader.set(uploader); + uploader.setChunkSize(content.length); + uploader.setRequestPayloadSize(content.length); + while (uploader.uploadChunk() > -1) { } + uploader.finish(); + successCalled.set(true); + } catch (Exception error) { + if (!aborted.get()) { + uploadError.set(error); + } + } + } + }); + uploadThread.start(); + uploadThread.join(5000); + if (uploadThread.isAlive()) { + uploadThread.interrupt(); + throw new IllegalStateException("timed out waiting for abort upload thread"); + } + if (uploadError.get() != null) { + throw uploadError.get(); + } + if (!aborted.get()) { + throw new IllegalStateException("abort scenario completed without aborting"); + } + if (terminateUploadOnAbort) { + final TusUploader uploader = activeUploader.get(); + if (uploader == null) { + throw new IllegalStateException( + "abort scenario requested termination before uploader was available" + ); + } + client.abortUpload(uploader, true); + } + + conformanceServer.assertExhausted(); + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("events", new JSONArray(events)); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", successCalled.get()); + result.put("uploadUrl", uploadUrlResult(conformanceServer, activeUploader.get())); + + return result; + } + } + + private static TusUpload uploadFor( + byte[] content, + String fingerprint, + Map metadata + ) { + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(fingerprint); + upload.setMetadata(metadata); + return upload; + } + + private static Object uploadUrlResult( + Api2DevdockTusConformanceServer conformanceServer, + TusUploader uploader + ) { + if (uploader == null || uploader.getUploadURL() == null) { + return JSONObject.NULL; + } + + return conformanceServer.canonicalUrl(uploader.getUploadURL().toString()); + } + + private static String runtimeFingerprint(JSONObject conformanceScenario, JSONObject scenario) { + final JSONObject runtimeSetup = conformanceScenario.getJSONObject("runtimeSetup"); + if (!runtimeSetup.has("fingerprint")) { + return scenario.getString("scenarioId") + "-java-abort-upload"; + } + + final JSONObject fingerprint = runtimeSetup.getJSONObject("fingerprint"); + if (!fingerprint.optBoolean("install", false)) { + return scenario.getString("scenarioId") + "-java-abort-upload"; + } + + return fingerprint.getString("value"); + } + + private Api2DevdockTusAbortUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java index 39c369db..41a47a13 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java @@ -16,20 +16,62 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; final class Api2DevdockTusConformanceServer implements AutoCloseable { + interface RequestAbortHandler { + void abortRequest(RequestAbortContext context) throws Exception; + } + + static final class RequestAbortContext { + private final int requestIndex; + private final String method; + private final String url; + + RequestAbortContext(int requestIndex, String method, String url) { + this.requestIndex = requestIndex; + this.method = method; + this.url = url; + } + + int requestIndex() { + return requestIndex; + } + + String method() { + return method; + } + + String url() { + return url; + } + } + private final URL endpointOrigin; private final List requests; private final HttpServer server; + private final ExecutorService executor; private final List errors; private final List requestHeaders; private final List requestMethods; private final List requestUrls; + private final RequestAbortHandler requestAbortHandler; private int nextRequestIndex; Api2DevdockTusConformanceServer(JSONObject conformanceScenario, URL endpointOrigin) throws IOException { + this(conformanceScenario, endpointOrigin, null); + } + + Api2DevdockTusConformanceServer( + JSONObject conformanceScenario, + URL endpointOrigin, + RequestAbortHandler requestAbortHandler + ) + throws IOException { this.endpointOrigin = endpointOrigin; + this.requestAbortHandler = requestAbortHandler; this.requests = new ArrayList(); final JSONArray requestArray = conformanceScenario.getJSONArray("requests"); for (int index = 0; index < requestArray.length(); index++) { @@ -40,12 +82,14 @@ final class Api2DevdockTusConformanceServer implements AutoCloseable { this.requestMethods = new ArrayList(); this.requestUrls = new ArrayList(); this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + this.executor = Executors.newCachedThreadPool(); server.createContext("/", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { handleRequest(exchange); } }); + server.setExecutor(executor); server.start(); } @@ -68,6 +112,10 @@ void assertExhausted() { + requests.size() + " conformance request(s), got " + nextRequestIndex + + "; observed methods " + + requestMethods + + "; observed URLs " + + requestUrls ); } @@ -101,12 +149,17 @@ JSONObject result() { @Override public void close() { server.stop(0); + executor.shutdownNow(); } private void handleRequest(HttpExchange exchange) throws IOException { try { final byte[] body = readRequestBody(exchange); final int requestIndex = observeRequest(exchange, body); + if (requests.get(requestIndex).optBoolean("abort", false)) { + abortRequest(exchange, requestIndex); + return; + } writeResponse(exchange, requests.get(requestIndex)); } catch (Exception error) { errors.add(error.getMessage()); @@ -118,6 +171,19 @@ private void handleRequest(HttpExchange exchange) throws IOException { } } + private void abortRequest(HttpExchange exchange, int requestIndex) throws Exception { + if (requestAbortHandler == null) { + throw new IllegalStateException("request " + requestIndex + " expected abort handler"); + } + + requestAbortHandler.abortRequest(new RequestAbortContext( + requestIndex, + requestMethods.get(requestIndex), + requestUrls.get(requestIndex) + )); + exchange.close(); + } + private int observeRequest(HttpExchange exchange, byte[] body) throws IOException { if (nextRequestIndex >= requests.size()) { throw new IllegalStateException( diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 33d4e927..468640df 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -29,6 +29,7 @@ public class TusClient { private Map headers; private int connectTimeout = 5000; private TusRequestLifecycleHooks requestLifecycleHooks; + private volatile HttpURLConnection currentConnection; /** * Create a new tus client. @@ -235,6 +236,38 @@ public void validateStartOptions(@NotNull TusStartOptions options) { TusStartOptionValidator.validate(options); } + /** + * Abort the currently active request, if any. + */ + public void abortUpload() { + abortCurrentRequest(); + } + + /** + * Abort an upload and optionally terminate the remote upload resource. + * + * @param uploader Uploader whose active request and upload URL should be aborted, or null to + * only abort the current request tracked by this client. + * @param terminateUpload True to issue a Termination request when the upload URL is known. + * @throws ProtocolException Thrown if the termination request receives an unexpected response. + * @throws IOException Thrown if the termination request fails. + */ + public void abortUpload(@Nullable TusUploader uploader, boolean terminateUpload) + throws ProtocolException, IOException { + if (uploader == null) { + abortCurrentRequest(); + return; + } + + uploader.abort(); + if (!terminateUpload) { + return; + } + + terminateUpload(uploader.getUploadURL()).disconnect(); + removeStoredUpload(uploader.getUpload()); + } + /** * Create a new upload using the Creation extension. Before calling this function, an "upload * creation URL" must be defined using {@link #setUploadCreationURL(URL)} or else this @@ -309,60 +342,65 @@ private TusUploader createUpload( connection.setFixedLengthStreamingMode(bytesToUpload); } - runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); - TusRequestSnapshot requestSnapshot = TusRequestSnapshot.fromConnection(connection); + registerCurrentRequest(connection); try { - if (bytesToUpload > 0) { - writeUploadCreationData(connection, upload, bytesToUpload); - } else { - connection.connect(); + runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); + TusRequestSnapshot requestSnapshot = TusRequestSnapshot.fromConnection(connection); + try { + if (bytesToUpload > 0) { + writeUploadCreationData(connection, upload, bytesToUpload); + } else { + connection.connect(); + } + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); } - } catch (IOException error) { - throw TusDetailedErrors.requestException( - TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, - requestSnapshot, - error - ); - } - int responseCode; - try { - responseCode = connection.getResponseCode(); - } catch (IOException error) { - throw TusDetailedErrors.requestException( - TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, - requestSnapshot, - error - ); - } - runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); - if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { - throw TusDetailedErrors.responseException( - TusProtocol.DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, - requestSnapshot, - connection - ); - } + int responseCode; + try { + responseCode = connection.getResponseCode(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw TusDetailedErrors.responseException( + TusProtocol.DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + requestSnapshot, + connection + ); + } - String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); - if (urlStr == null || urlStr.length() == 0) { - throw new ProtocolException("missing upload URL in response for creating upload", connection); - } + String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); + if (urlStr == null || urlStr.length() == 0) { + throw new ProtocolException("missing upload URL in response for creating upload", connection); + } - // The upload URL must be relative to the URL of the request by which is was returned, - // not the upload creation URL. In most cases, there is no difference between those two - // but there may be cases in which the POST request is redirected. - URL uploadURL = new URL(connection.getURL(), urlStr); + // The upload URL must be relative to the URL of the request by which is was returned, + // not the upload creation URL. In most cases, there is no difference between those two + // but there may be cases in which the POST request is redirected. + URL uploadURL = new URL(connection.getURL(), urlStr); - long offset = bytesToUpload > 0 - ? readUploadCreationOffset(connection, bytesToUpload) - : 0L; + long offset = bytesToUpload > 0 + ? readUploadCreationOffset(connection, bytesToUpload) + : 0L; - if (resumingEnabled) { - urlStore.set(upload.getFingerprint(), uploadURL); - } + if (resumingEnabled) { + urlStore.set(upload.getFingerprint(), uploadURL); + } - return createUploader(upload, uploadURL, offset, bytesToUpload > 0); + return createUploader(upload, uploadURL, offset, bytesToUpload > 0); + } finally { + clearCurrentRequest(connection); + } } private static void prepareUploadCreationHeaders( @@ -461,18 +499,23 @@ public HttpURLConnection terminateUpload(@NotNull URL uploadURL) connection.setRequestMethod(TusProtocol.TERMINATE_UPLOAD_METHOD); prepareConnection(connection); - runBeforeRequest(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); - connection.connect(); + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); + connection.connect(); + + int responseCode = connection.getResponseCode(); + runAfterResponse(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw new ProtocolException( + "unexpected status code (" + responseCode + ") while terminating upload", + connection); + } - int responseCode = connection.getResponseCode(); - runAfterResponse(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); - if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { - throw new ProtocolException( - "unexpected status code (" + responseCode + ") while terminating upload", - connection); + return connection; + } finally { + clearCurrentRequest(connection); } - - return connection; } /** @@ -575,23 +618,28 @@ public TusUploader beginOrResumeUploadFromURL(@NotNull TusUpload upload, @NotNul connection.setRequestMethod(TusProtocol.OFFSET_DISCOVERY_METHOD); prepareConnection(connection); - runBeforeRequest(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); - connection.connect(); + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); + connection.connect(); + + int responseCode = connection.getResponseCode(); + runAfterResponse(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw new ProtocolException( + "unexpected status code (" + responseCode + ") while resuming upload", connection); + } - int responseCode = connection.getResponseCode(); - runAfterResponse(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); - if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { - throw new ProtocolException( - "unexpected status code (" + responseCode + ") while resuming upload", connection); - } + String offsetStr = connection.getHeaderField(TusProtocol.UPLOAD_OFFSET_HEADER_NAME); + if (offsetStr == null || offsetStr.length() == 0) { + throw new ProtocolException("missing upload offset in response for resuming upload", connection); + } + long offset = Long.parseLong(offsetStr); - String offsetStr = connection.getHeaderField(TusProtocol.UPLOAD_OFFSET_HEADER_NAME); - if (offsetStr == null || offsetStr.length() == 0) { - throw new ProtocolException("missing upload offset in response for resuming upload", connection); + return createUploader(upload, uploadURL, offset); + } finally { + clearCurrentRequest(connection); } - long offset = Long.parseLong(offsetStr); - - return createUploader(upload, uploadURL, offset); } /** @@ -668,6 +716,31 @@ final void runAfterResponse( ); } + final void registerCurrentRequest(@NotNull HttpURLConnection connection) { + currentConnection = connection; + } + + final void clearCurrentRequest(@NotNull HttpURLConnection connection) { + if (currentConnection == connection) { + currentConnection = null; + } + } + + private void abortCurrentRequest() { + HttpURLConnection connection = currentConnection; + if (connection != null) { + connection.disconnect(); + } + } + + private void removeStoredUpload(@NotNull TusUpload upload) { + if (!resumingEnabled) { + return; + } + + urlStore.remove(upload.getFingerprint()); + } + /** * Actions to be performed after a successful upload completion. * Manages URL removal from the URL store if remove fingerprint on success is enabled diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index da73f5b8..f9c9bc34 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -61,6 +61,7 @@ public interface ChunkCompleteListener { private boolean uploadLengthDeclared; private ProgressListener progressListener; private ChunkCompleteListener chunkCompleteListener; + private volatile boolean aborted; private HttpURLConnection connection; private OutputStream output; @@ -122,6 +123,7 @@ private void openConnection() throws IOException, ProtocolException { connection = (HttpURLConnection) uploadURL.openConnection(); } client.prepareConnection(connection); + client.registerCurrentRequest(connection); connection.setRequestProperty(TusProtocol.UPLOAD_OFFSET_HEADER_NAME, Long.toString(offset)); if (shouldDeclareUploadLength()) { connection.setRequestProperty(TusProtocol.UPLOAD_LENGTH_HEADER_NAME, Long.toString(upload.getSize())); @@ -144,6 +146,7 @@ private void openConnection() throws IOException, ProtocolException { connection.setDoOutput(true); connection.setChunkedStreamingMode(0); client.runBeforeRequest(TusProtocol.UPLOAD_CHUNK_METHOD, connection); + throwIfAborted(); try { output = connection.getOutputStream(); } catch (java.net.ProtocolException pe) { @@ -266,11 +269,13 @@ public void setChunkCompleteListener(ChunkCompleteListener listener) { * to the HTTP request. */ public int uploadChunk() throws IOException, ProtocolException { + throwIfAborted(); if (isUploadComplete()) { return -1; } openConnection(); + throwIfAborted(); notifyProgressAtRequestStart(); int bytesToRead = Math.min(getChunkSize(), bytesRemainingForRequest); @@ -284,8 +289,16 @@ public int uploadChunk() throws IOException, ProtocolException { // Do not write the entire buffer to the stream since the array will // be filled up with 0x00s if the number of read bytes is lower then // the chunk's size. - output.write(buffer, 0, bytesRead); - output.flush(); + try { + output.write(buffer, 0, bytesRead); + output.flush(); + } catch (IOException error) { + if (aborted) { + cleanupConnection(); + } + throw error; + } + throwIfAborted(); offset += bytesRead; bytesRemainingForRequest -= bytesRead; @@ -324,11 +337,13 @@ public int uploadChunk() throws IOException, ProtocolException { * to the HTTP request. */ @Deprecated public int uploadChunk(int chunkSize) throws IOException, ProtocolException { + throwIfAborted(); if (isUploadComplete()) { return -1; } openConnection(); + throwIfAborted(); byte[] buf = new byte[chunkSize]; int bytesRead = input.read(buf, chunkSize); @@ -340,8 +355,16 @@ public int uploadChunk() throws IOException, ProtocolException { // Do not write the entire buffer to the stream since the array will // be filled up with 0x00s if the number of read bytes is lower then // the chunk's size. - output.write(buf, 0, bytesRead); - output.flush(); + try { + output.write(buf, 0, bytesRead); + output.flush(); + } catch (IOException error) { + if (aborted) { + cleanupConnection(); + } + throw error; + } + throwIfAborted(); offset += bytesRead; @@ -367,6 +390,26 @@ public URL getUploadURL() { return uploadURL; } + /** + * Abort the active upload request, if any. + */ + public void abort() { + aborted = true; + HttpURLConnection currentConnection = connection; + if (currentConnection != null) { + currentConnection.disconnect(); + } + } + + /** + * Get whether this uploader has been aborted. + * + * @return True when {@link #abort()} has been called. + */ + public boolean isAborted() { + return aborted; + } + /** * Set the proxy that will be used when uploading. * @@ -424,13 +467,18 @@ public void finish(boolean closeInputStream) throws ProtocolException, IOExcepti } private void finishConnection() throws ProtocolException, IOException { - if (output != null) { - output.close(); + if (aborted) { + cleanupConnection(); + return; } if (connection != null) { HttpURLConnection currentConnection = connection; try { + if (output != null) { + output.close(); + } + int responseCode = currentConnection.getResponseCode(); client.runAfterResponse(TusProtocol.UPLOAD_CHUNK_METHOD, currentConnection); @@ -461,15 +509,23 @@ private void finishConnection() throws ProtocolException, IOException { } notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); } finally { - currentConnection.disconnect(); - connection = null; - output = null; - requestDeclaresUploadLength = false; - requestProgressStarted = false; + cleanupConnection(); } } } + private void cleanupConnection() { + HttpURLConnection currentConnection = connection; + if (currentConnection != null) { + currentConnection.disconnect(); + client.clearCurrentRequest(currentConnection); + } + connection = null; + output = null; + requestDeclaresUploadLength = false; + requestProgressStarted = false; + } + private void notifyProgressAtRequestStart() { if (!requestProgressStarted) { notifyProgress(offset); @@ -505,4 +561,14 @@ private long getHeaderFieldLong(URLConnection connection, String field) { return -1; } } + + TusUpload getUpload() { + return upload; + } + + private void throwIfAborted() throws IOException { + if (aborted) { + throw new IOException("upload aborted"); + } + } } diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index c2f5da90..a38db546 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -702,6 +702,42 @@ public void testBeginOrResumeUploadFromURL() throws IOException, ProtocolExcepti assertEquals(uploader.getOffset(), 3); } + /** + * Tests if aborting with termination deletes the remote upload and removes stored fingerprints. + * @throws IOException if the upload cannot be constructed or terminated. + * @throws ProtocolException if the upload cannot be terminated. + */ + @Test + public void testAbortUploadTerminatesAndRemovesFingerprint() + throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("DELETE") + .withPath("/files/abort"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204))); + + TusClient client = new TusClient(); + TusURLStore store = new TusURLMemoryStore(); + URL uploadURL = new URL(mockServerURL.toString() + "/abort"); + store.set("fingerprint", uploadURL); + client.enableResuming(store); + + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + upload.setFingerprint("fingerprint"); + TusUploader uploader = new TusUploader(client, upload, uploadURL, upload.getTusInputStream(), 0); + + client.abortUpload(uploader, true); + + assertTrue(uploader.isAborted()); + assertNull(store.get("fingerprint")); + HttpRequest[] deleteRequests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("DELETE") + .withPath("/files/abort")); + assertEquals(1, deleteRequests.length); + } + /** * Tests if connections are prepared correctly, which means all header are getting set. * @throws IOException From a49b672dcbf0c8dd2478dc34e0c874f7b085c608 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 09:13:13 +0200 Subject: [PATCH 92/96] Fix Java abort proof lint --- .../java/io/tus/java/example/Api2DevdockTusAbortUpload.java | 5 ++++- src/main/java/io/tus/java/client/TusUploader.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java index cc33c278..6ec814f8 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java @@ -116,7 +116,10 @@ public void run() { activeUploader.set(uploader); uploader.setChunkSize(content.length); uploader.setRequestPayloadSize(content.length); - while (uploader.uploadChunk() > -1) { } + int uploadProgress = uploader.uploadChunk(); + while (uploadProgress > -1) { + uploadProgress = uploader.uploadChunk(); + } uploader.finish(); successCalled.set(true); } catch (Exception error) { diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index f9c9bc34..e5915c22 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -562,7 +562,7 @@ private long getHeaderFieldLong(URLConnection connection, String field) { } } - TusUpload getUpload() { + final TusUpload getUpload() { return upload; } From 7c2305a1c38e709ed176c33062db87298d817b3f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 12:14:00 +0200 Subject: [PATCH 93/96] Add TUS parallel concat proof --- example/build.gradle | 6 + .../Api2DevdockTusConformanceServer.java | 402 ++++++++++++++++-- .../Api2DevdockTusParallelUploadConcat.java | 300 +++++++++++++ .../java/io/tus/java/client/TusClient.java | 125 ++++++ .../java/io/tus/java/client/TusProtocol.java | 8 + .../java/io/tus/java/client/TusUpload.java | 4 + .../io/tus/java/client/TestTusClient.java | 73 ++++ 7 files changed, 888 insertions(+), 30 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java diff --git a/example/build.gradle b/example/build.gradle index c4bbb313..56a9dabb 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -66,6 +66,12 @@ tasks.register('api2DevdockTusStartOptionValidation', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusParallelUploadConcat', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusParallelUploadConcat' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java index 41a47a13..c1477f69 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java @@ -49,15 +49,22 @@ String url() { } private final URL endpointOrigin; + private final byte[] inputSourceContent; private final List requests; + private final boolean[] observedRequests; + private final List requestGates; private final HttpServer server; private final ExecutorService executor; private final List errors; - private final List requestHeaders; - private final List requestMethods; - private final List requestUrls; + private final JSONObject[] absentHeaderPresence; + private final Integer[] requestBodySizes; + private final Integer[] requestBodyStarts; + private final JSONObject[] requestHeaders; + private final String[] requestMethods; + private final String[] requestUrls; private final RequestAbortHandler requestAbortHandler; - private int nextRequestIndex; + private int nextSequentialRequestIndex; + private int observedRequestCount; Api2DevdockTusConformanceServer(JSONObject conformanceScenario, URL endpointOrigin) throws IOException { @@ -72,15 +79,21 @@ String url() { throws IOException { this.endpointOrigin = endpointOrigin; this.requestAbortHandler = requestAbortHandler; + this.inputSourceContent = inputSourceContent(conformanceScenario); this.requests = new ArrayList(); final JSONArray requestArray = conformanceScenario.getJSONArray("requests"); for (int index = 0; index < requestArray.length(); index++) { requests.add(requestArray.getJSONObject(index)); } + this.observedRequests = new boolean[requests.size()]; + this.requestGates = requestGates(conformanceScenario); this.errors = new ArrayList(); - this.requestHeaders = new ArrayList(); - this.requestMethods = new ArrayList(); - this.requestUrls = new ArrayList(); + this.absentHeaderPresence = new JSONObject[requests.size()]; + this.requestBodySizes = new Integer[requests.size()]; + this.requestBodyStarts = new Integer[requests.size()]; + this.requestHeaders = new JSONObject[requests.size()]; + this.requestMethods = new String[requests.size()]; + this.requestUrls = new String[requests.size()]; this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); this.executor = Executors.newCachedThreadPool(); server.createContext("/", new HttpHandler() { @@ -103,7 +116,7 @@ URL localUrlFor(String canonicalUrl) throws IOException { void assertExhausted() { assertNoErrors(); - if (nextRequestIndex == requests.size()) { + if (observedRequestCount == requests.size()) { return; } @@ -111,11 +124,11 @@ void assertExhausted() { "expected " + requests.size() + " conformance request(s), got " - + nextRequestIndex + + observedRequestCount + "; observed methods " - + requestMethods + + observedStrings(requestMethods) + "; observed URLs " - + requestUrls + + observedStrings(requestUrls) ); } @@ -128,7 +141,7 @@ void assertNoErrors() { } String canonicalUrl(String actualUrl) { - return actualUrl.replace(localOrigin(), canonicalOrigin()); + return canonicalValue(actualUrl); } String errorSummary() { @@ -141,9 +154,12 @@ String errorSummary() { JSONObject result() { return new JSONObject() - .put("requestHeaders", new JSONArray(requestHeaders)) - .put("requestMethods", new JSONArray(requestMethods)) - .put("requestUrls", new JSONArray(requestUrls)); + .put("absentHeaderPresence", observedObjects(absentHeaderPresence)) + .put("requestBodySizes", observedIntegers(requestBodySizes)) + .put("requestBodyStarts", observedIntegers(requestBodyStarts)) + .put("requestHeaders", observedObjects(requestHeaders)) + .put("requestMethods", observedStrings(requestMethods)) + .put("requestUrls", observedStrings(requestUrls)); } @Override @@ -152,6 +168,73 @@ public void close() { executor.shutdownNow(); } + private static List requestGates(JSONObject conformanceScenario) { + final List result = new ArrayList(); + final JSONObject execution = conformanceScenario.optJSONObject("execution"); + if (execution == null) { + return result; + } + + final JSONArray gates = execution.optJSONArray("serverRequestGates"); + if (gates == null) { + return result; + } + + for (int index = 0; index < gates.length(); index++) { + result.add(new RequestGate(gates.getJSONObject(index))); + } + + return result; + } + + private JSONArray observedIntegers(Integer[] values) { + final JSONArray result = new JSONArray(); + for (int index = 0; index < values.length; index++) { + if (observedRequests[index]) { + result.put(values[index] == null ? JSONObject.NULL : values[index]); + } + } + + return result; + } + + private static byte[] inputSourceContent(JSONObject conformanceScenario) { + final JSONObject inputSource = conformanceScenario.optJSONObject("inputSource"); + if (inputSource == null || !"blob".equals(inputSource.optString("kind"))) { + return null; + } + + return inputSource.getString("content").getBytes(StandardCharsets.UTF_8); + } + + private void awaitRequestGate(int requestIndex) throws InterruptedException { + for (RequestGate gate : requestGates) { + gate.awaitIfHeld(requestIndex); + } + } + + private JSONArray observedStrings(String[] values) { + final JSONArray result = new JSONArray(); + for (int index = 0; index < values.length; index++) { + if (observedRequests[index]) { + result.put(values[index]); + } + } + + return result; + } + + private JSONArray observedObjects(JSONObject[] values) { + final JSONArray result = new JSONArray(); + for (int index = 0; index < values.length; index++) { + if (observedRequests[index]) { + result.put(values[index]); + } + } + + return result; + } + private void handleRequest(HttpExchange exchange) throws IOException { try { final byte[] body = readRequestBody(exchange); @@ -160,6 +243,7 @@ private void handleRequest(HttpExchange exchange) throws IOException { abortRequest(exchange, requestIndex); return; } + awaitRequestGate(requestIndex); writeResponse(exchange, requests.get(requestIndex)); } catch (Exception error) { errors.add(error.getMessage()); @@ -178,14 +262,14 @@ private void abortRequest(HttpExchange exchange, int requestIndex) throws Except requestAbortHandler.abortRequest(new RequestAbortContext( requestIndex, - requestMethods.get(requestIndex), - requestUrls.get(requestIndex) + requestMethods[requestIndex], + requestUrls[requestIndex] )); exchange.close(); } - private int observeRequest(HttpExchange exchange, byte[] body) throws IOException { - if (nextRequestIndex >= requests.size()) { + private synchronized int observeRequest(HttpExchange exchange, byte[] body) throws IOException { + if (observedRequestCount >= requests.size()) { throw new IllegalStateException( "unexpected request " + exchange.getRequestMethod() @@ -194,9 +278,98 @@ private int observeRequest(HttpExchange exchange, byte[] body) throws IOExceptio ); } - final int requestIndex = nextRequestIndex; - final JSONObject requestPlan = requests.get(requestIndex); final String actualUrl = canonicalRequestUrl(exchange.getRequestURI()); + final int requestIndex = matchingRequestIndex(exchange, actualUrl, body); + if (requestIndex < 0) { + throw new IllegalStateException( + "unexpected request " + + exchange.getRequestMethod() + + " " + + actualUrl + + "; next planned request " + + nextSequentialRequestIndex + ); + } + final JSONObject requestPlan = requests.get(requestIndex); + assertRequestMatchesPlan(requestIndex, requestPlan, exchange, actualUrl, body); + assertRequestBodyContent(requestIndex, requestPlan, body); + assertAbsentHeaders(requestIndex, requestPlan, exchange.getRequestHeaders()); + final JSONObject expectedHeaders = requestPlan.getJSONObject("effectiveHeaders"); + assertHeaders(requestIndex, expectedHeaders, exchange.getRequestHeaders()); + + absentHeaderPresence[requestIndex] = capturedAbsentHeaderPresence( + requestPlan, + exchange.getRequestHeaders() + ); + requestBodySizes[requestIndex] = requestPlan.isNull("bodySize") + ? null + : Integer.valueOf(body.length); + requestBodyStarts[requestIndex] = requestPlan.has("bodyStart") && !requestPlan.isNull("bodyStart") + ? Integer.valueOf(requestPlan.getInt("bodyStart")) + : null; + requestMethods[requestIndex] = exchange.getRequestMethod(); + requestUrls[requestIndex] = actualUrl; + requestHeaders[requestIndex] = capturedHeaders(expectedHeaders, exchange.getRequestHeaders()); + observedRequests[requestIndex] = true; + observedRequestCount += 1; + while ( + nextSequentialRequestIndex < observedRequests.length + && observedRequests[nextSequentialRequestIndex] + ) { + nextSequentialRequestIndex += 1; + } + + return requestIndex; + } + + private int matchingRequestIndex( + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + if (requestMatchesPlan(nextSequentialRequestIndex, exchange, actualUrl, body)) { + return nextSequentialRequestIndex; + } + + for (RequestGate gate : requestGates) { + final int requestIndex = gate.matchingHeldRequestIndex( + observedRequests, + requests, + exchange, + actualUrl, + body + ); + if (requestIndex >= 0) { + return requestIndex; + } + } + + return -1; + } + + private boolean requestMatchesPlan( + int requestIndex, + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + if (requestIndex < 0 || requestIndex >= requests.size() || observedRequests[requestIndex]) { + return false; + } + + final JSONObject requestPlan = requests.get(requestIndex); + return requestPlan.getString("effectiveMethod").equals(exchange.getRequestMethod()) + && requestPlan.getString("expectedUrl").equals(actualUrl) + && (requestPlan.isNull("bodySize") || body.length == requestPlan.getInt("bodySize")); + } + + private void assertRequestMatchesPlan( + int requestIndex, + JSONObject requestPlan, + HttpExchange exchange, + String actualUrl, + byte[] body + ) { final String expectedUrl = requestPlan.getString("expectedUrl"); final String expectedMethod = requestPlan.getString("effectiveMethod"); if (!expectedMethod.equals(exchange.getRequestMethod())) { @@ -229,20 +402,62 @@ private int observeRequest(HttpExchange exchange, byte[] body) throws IOExceptio + body.length ); } - final JSONObject expectedHeaders = requestPlan.getJSONObject("effectiveHeaders"); - assertHeaders(requestIndex, expectedHeaders, exchange.getRequestHeaders()); + } - requestMethods.add(exchange.getRequestMethod()); - requestUrls.add(actualUrl); - requestHeaders.add(capturedHeaders(expectedHeaders, exchange.getRequestHeaders())); - nextRequestIndex += 1; + private void assertRequestBodyContent(int requestIndex, JSONObject requestPlan, byte[] body) { + if (!requestPlan.has("bodyStart") || requestPlan.isNull("bodyStart") || inputSourceContent == null) { + return; + } - return requestIndex; + final int bodyStart = requestPlan.getInt("bodyStart"); + if (bodyStart + body.length > inputSourceContent.length) { + throw new IllegalStateException( + "request " + + requestIndex + + " body range " + + bodyStart + + ".." + + (bodyStart + body.length) + + " exceeds input source length " + + inputSourceContent.length + ); + } + for (int index = 0; index < body.length; index++) { + final byte expected = inputSourceContent[bodyStart + index]; + if (body[index] == expected) { + continue; + } + + throw new IllegalStateException( + "request " + + requestIndex + + " body byte " + + index + + " expected " + + expected + + ", got " + + body[index] + ); + } + } + + private void assertAbsentHeaders(int requestIndex, JSONObject requestPlan, Headers actualHeaders) { + final JSONArray absentHeaders = requestPlan.getJSONArray("absentHeaders"); + for (int index = 0; index < absentHeaders.length(); index++) { + final String name = absentHeaders.getString(index); + if (actualHeaders.getFirst(name) == null) { + continue; + } + + throw new IllegalStateException( + "request " + requestIndex + " expected header " + name + " to be absent" + ); + } } private void assertHeaders(int requestIndex, JSONObject expectedHeaders, Headers actualHeaders) { for (String name : expectedHeaders.keySet()) { - final String expectedValue = expectedHeaders.getString(name); + final String expectedValue = localValue(expectedHeaders.getString(name)); final String actualValue = actualHeaders.getFirst(name); if (expectedValue.equals(actualValue)) { continue; @@ -266,13 +481,27 @@ private JSONObject capturedHeaders(JSONObject expectedHeaders, Headers actualHea for (String name : expectedHeaders.keySet()) { final String value = actualHeaders.getFirst(name); if (value != null) { - result.put(name, value); + result.put(name, canonicalValue(value)); } } return result; } + private JSONObject capturedAbsentHeaderPresence( + JSONObject requestPlan, + Headers actualHeaders + ) { + final JSONObject result = new JSONObject(); + final JSONArray absentHeaders = requestPlan.getJSONArray("absentHeaders"); + for (int index = 0; index < absentHeaders.length(); index++) { + final String name = absentHeaders.getString(index); + result.put(name, actualHeaders.getFirst(name) != null); + } + + return result; + } + private void writeResponse(HttpExchange exchange, JSONObject requestPlan) throws IOException { final JSONObject responsePlan = requestPlan.getJSONObject("response"); final Headers headers = exchange.getResponseHeaders(); @@ -302,6 +531,10 @@ private String localValue(String value) { return value.replace(canonicalOrigin(), localOrigin()); } + private String canonicalValue(String value) { + return value.replace(localOrigin(), canonicalOrigin()); + } + private String canonicalRequestUrl(URI requestUri) throws IOException { return new URL(endpointOrigin, requestUri.toString()).toString(); } @@ -326,6 +559,115 @@ private String localOrigin() { return "http://" + address.getHostString() + ":" + address.getPort(); } + private static final class RequestGate { + private final List heldRequestIndexes; + private final List releaseAfterRequestIndexes; + private final long timeoutMs; + private final List arrivedRequestIndexes; + private boolean released; + + RequestGate(JSONObject gate) { + if (!"release-after-all-started".equals(gate.getString("kind"))) { + throw new IllegalArgumentException( + "unsupported conformance server request gate " + gate.getString("kind") + ); + } + + heldRequestIndexes = integerList(gate.getJSONArray("heldRequestIndexes")); + releaseAfterRequestIndexes = integerList(gate.getJSONArray("releaseAfterRequestIndexes")); + timeoutMs = gate.getLong("timeoutMs"); + arrivedRequestIndexes = new ArrayList(); + } + + int matchingHeldRequestIndex( + boolean[] observedRequests, + List requests, + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + for (Integer requestIndex : heldRequestIndexes) { + final int index = requestIndex.intValue(); + if (index < 0 || index >= requests.size() || observedRequests[index]) { + continue; + } + + final JSONObject requestPlan = requests.get(index); + if (!requestPlan.getString("effectiveMethod").equals(exchange.getRequestMethod())) { + continue; + } + if (!requestPlan.getString("expectedUrl").equals(actualUrl)) { + continue; + } + if (!requestPlan.isNull("bodySize") && body.length != requestPlan.getInt("bodySize")) { + continue; + } + + return index; + } + + return -1; + } + + synchronized void awaitIfHeld(int requestIndex) throws InterruptedException { + if (!contains(heldRequestIndexes, requestIndex)) { + return; + } + + if (!contains(arrivedRequestIndexes, requestIndex)) { + arrivedRequestIndexes.add(Integer.valueOf(requestIndex)); + } + if (containsAll(arrivedRequestIndexes, releaseAfterRequestIndexes)) { + released = true; + notifyAll(); + return; + } + + final long deadline = System.currentTimeMillis() + timeoutMs; + while (!released) { + final long remainingMs = deadline - System.currentTimeMillis(); + if (remainingMs <= 0) { + throw new IllegalStateException( + "timed out waiting for conformance request gate; arrived " + + arrivedRequestIndexes + + ", expected " + + releaseAfterRequestIndexes + ); + } + wait(remainingMs); + } + } + + private static List integerList(JSONArray values) { + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + result.add(Integer.valueOf(values.getInt(index))); + } + + return result; + } + + private static boolean contains(List values, int expected) { + for (Integer value : values) { + if (value.intValue() == expected) { + return true; + } + } + + return false; + } + + private static boolean containsAll(List values, List expectedValues) { + for (Integer expected : expectedValues) { + if (!contains(values, expected.intValue())) { + return false; + } + } + + return true; + } + } + private Api2DevdockTusConformanceServer() { throw new IllegalStateException("Utility class"); } diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java new file mode 100644 index 00000000..86f92008 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java @@ -0,0 +1,300 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public final class Api2DevdockTusParallelUploadConcat { + /** + * Run the API2 devdock TUS parallel-upload concat example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadWithParallelConcat(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " concatenated parallel uploads into " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithParallelConcat(JSONObject scenario) throws Exception { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final Map partialMetadata = + Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadataForPartialUploads" + ); + final int parallelUploads = Api2DevdockScenario.conformanceInputIntegerOption( + conformanceScenario, + "parallelUploads", + 1 + ); + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + final EventKinds eventKinds = EventKinds.from(conformanceScenario); + final List events = Collections.synchronizedList(new ArrayList()); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) { + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final List parts = partUploads( + conformanceScenario, + scenario.getString("scenarioId"), + content, + partialMetadata + ); + if (parts.size() != parallelUploads) { + throw new IllegalStateException( + "parallel concat expected " + + parallelUploads + + " part(s), got " + + parts.size() + ); + } + + for (PartUpload part : parts) { + part.uploader = client.createPartialUpload(part.upload); + } + + runPartialUploads(parts, events, eventKinds, content.length); + final List partialURLs = new ArrayList(); + for (PartUpload part : parts) { + partialURLs.add(part.uploader.getUploadURL()); + } + final URL finalUploadURL = client.concatenateUploads(partialURLs, metadata); + + conformanceServer.assertExhausted(); + final JSONArray sortedEvents = sortedEvents(events, eventKinds); + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("eventCount", sortedEvents.length()); + result.put("events", sortedEvents); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", true); + result.put("uploadUrl", conformanceServer.canonicalUrl(finalUploadURL.toString())); + + return result; + } + } + + private static List partUploads( + JSONObject conformanceScenario, + String scenarioId, + byte[] content, + Map metadata + ) { + final JSONArray requests = conformanceScenario.getJSONArray("requests"); + final List result = new ArrayList(); + for (int requestIndex = 0; requestIndex < requests.length(); requestIndex++) { + final JSONObject request = requests.getJSONObject(requestIndex); + if (!"upload-partial-chunk".equals(request.optString("role"))) { + continue; + } + + final int bodyStart = request.optInt("bodyStart", 0); + final int bodySize = request.getInt("bodySize"); + final byte[] partContent = new byte[bodySize]; + System.arraycopy(content, bodyStart, partContent, 0, bodySize); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(partContent)); + upload.setSize(bodySize); + upload.setFingerprint( + scenarioId + "-java-parallel-upload-concat-" + result.size() + ); + upload.setMetadata(metadata); + result.add(new PartUpload(bodyStart, bodySize, upload)); + } + + return result; + } + + private static void runPartialUploads( + List parts, + List events, + EventKinds eventKinds, + int totalSize + ) throws Exception { + final List threads = new ArrayList(); + final AtomicReference uploadError = new AtomicReference(); + for (final PartUpload part : parts) { + final Thread thread = new Thread(new Runnable() { + @Override + public void run() { + try { + uploadPart(part, events, eventKinds, totalSize); + } catch (Exception error) { + uploadError.compareAndSet(null, error); + } + } + }); + threads.add(thread); + thread.start(); + } + + for (Thread thread : threads) { + thread.join(5000); + if (thread.isAlive()) { + thread.interrupt(); + throw new IllegalStateException("timed out waiting for parallel concat upload"); + } + } + if (uploadError.get() != null) { + throw uploadError.get(); + } + } + + private static void uploadPart( + final PartUpload part, + final List events, + final EventKinds eventKinds, + final int totalSize + ) throws Exception { + part.uploader.setChunkSize(part.bodySize); + part.uploader.setRequestPayloadSize(part.bodySize); + part.uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + if (bytesSent <= 0) { + return; + } + + events.add(new JSONObject() + .put("bytesSent", part.bodyStart + bytesSent) + .put("bytesTotal", totalSize) + .put("kind", eventKinds.progress)); + } + }); + part.uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add(new JSONObject() + .put("bytesAccepted", part.bodyStart + bytesAccepted) + .put("bytesTotal", totalSize) + .put("chunkSize", chunkSize) + .put("kind", eventKinds.chunkComplete)); + } + }); + + int uploadProgress = part.uploader.uploadChunk(); + while (uploadProgress > -1) { + uploadProgress = part.uploader.uploadChunk(); + } + part.uploader.finish(); + } + + private static JSONArray sortedEvents(List events, final EventKinds eventKinds) { + final List sorted = new ArrayList(events); + Collections.sort(sorted, new Comparator() { + @Override + public int compare(JSONObject left, JSONObject right) { + final int positionCompare = Long.compare(eventPosition(left), eventPosition(right)); + if (positionCompare != 0) { + return positionCompare; + } + + return Integer.compare(eventKindRank(left, eventKinds), eventKindRank(right, eventKinds)); + } + }); + + return new JSONArray(sorted); + } + + private static long eventPosition(JSONObject event) { + if (event.has("bytesSent")) { + return event.getLong("bytesSent"); + } + return event.getLong("bytesAccepted"); + } + + private static int eventKindRank(JSONObject event, EventKinds eventKinds) { + if (eventKinds.progress.equals(event.getString("kind"))) { + return 0; + } + return 1; + } + + private static final class EventKinds { + final String chunkComplete; + final String progress; + + EventKinds(String chunkComplete, String progress) { + this.chunkComplete = chunkComplete; + this.progress = progress; + } + + static EventKinds from(JSONObject conformanceScenario) { + final JSONArray events = conformanceScenario.getJSONArray("events"); + String chunkComplete = null; + String progress = null; + for (int index = 0; index < events.length(); index++) { + final JSONObject event = events.getJSONObject(index); + if (event.has("bytesSent")) { + progress = event.getString("kind"); + } + if (event.has("chunkSize")) { + chunkComplete = event.getString("kind"); + } + } + if (chunkComplete == null || progress == null) { + throw new IllegalArgumentException("parallel concat scenario is missing event kinds"); + } + + return new EventKinds(chunkComplete, progress); + } + } + + private static final class PartUpload { + final int bodySize; + final int bodyStart; + final TusUpload upload; + TusUploader uploader; + + PartUpload(int bodyStart, int bodySize, TusUpload upload) { + this.bodyStart = bodyStart; + this.bodySize = bodySize; + this.upload = upload; + } + } + + private Api2DevdockTusParallelUploadConcat() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 468640df..7c19dce6 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -6,6 +6,7 @@ import java.net.HttpURLConnection; import java.net.Proxy; import java.net.URL; +import java.util.List; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -285,6 +286,20 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept return createUpload(upload, 0); } + /** + * Create a partial upload using the Concatenation extension. + * + * @param upload The partial upload source. + * @return Use {@link TusUploader} to upload the partial file bytes. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes or missing/invalid headers. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public TusUploader createPartialUpload(@NotNull TusUpload upload) + throws ProtocolException, IOException { + return createUpload(upload, 0, true); + } + /** * Create a new upload and send the first bytes in the creation request using the * Creation With Upload extension. Before calling this function, an "upload creation URL" @@ -327,10 +342,24 @@ public TusUploader createUploadWithData( private TusUploader createUpload( @NotNull TusUpload upload, int bytesToUpload + ) throws ProtocolException, IOException { + return createUpload(upload, bytesToUpload, false); + } + + private TusUploader createUpload( + @NotNull TusUpload upload, + int bytesToUpload, + boolean partialUpload ) throws ProtocolException, IOException { HttpURLConnection connection = openConnection(uploadCreationURL); connection.setRequestMethod(TusProtocol.CREATE_UPLOAD_METHOD); prepareConnection(connection); + if (partialUpload) { + connection.setRequestProperty( + TusProtocol.CONCATENATION_HEADER_NAME, + TusProtocol.CONCATENATION_PARTIAL_VALUE + ); + } prepareUploadCreationHeaders(connection, upload); if (bytesToUpload > 0) { @@ -403,6 +432,80 @@ private TusUploader createUpload( } } + /** + * Create the final upload resource by concatenating partial upload URLs. + * + * @param uploadURLs Partial upload URLs in concatenation order. + * @param metadata Metadata for the final upload, or null. + * @return The final upload URL. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes or missing/invalid headers. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public URL concatenateUploads( + @NotNull List uploadURLs, + @Nullable Map metadata + ) throws ProtocolException, IOException { + if (uploadURLs.isEmpty()) { + throw new IllegalArgumentException("at least one partial upload URL is required"); + } + + HttpURLConnection connection = openConnection(uploadCreationURL); + connection.setRequestMethod(TusProtocol.CREATE_UPLOAD_METHOD); + prepareConnection(connection); + connection.setRequestProperty( + TusProtocol.CONCATENATION_HEADER_NAME, + finalUploadConcatValue(uploadURLs) + ); + prepareUploadMetadataHeaders(connection, metadata); + + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); + TusRequestSnapshot requestSnapshot = TusRequestSnapshot.fromConnection(connection); + try { + connection.connect(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + + int responseCode; + try { + responseCode = connection.getResponseCode(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw TusDetailedErrors.responseException( + TusProtocol.DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + requestSnapshot, + connection + ); + } + + String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); + if (urlStr == null || urlStr.length() == 0) { + throw new ProtocolException( + "missing upload URL in response for concatenating uploads", + connection + ); + } + + return new URL(connection.getURL(), urlStr); + } finally { + clearCurrentRequest(connection); + } + } + private static void prepareUploadCreationHeaders( @NotNull HttpURLConnection connection, @NotNull TusUpload upload @@ -422,6 +525,28 @@ private static void prepareUploadCreationHeaders( } } + private static void prepareUploadMetadataHeaders( + @NotNull HttpURLConnection connection, + @Nullable Map metadata + ) { + String encodedMetadata = TusUpload.encodeMetadata(metadata); + if (encodedMetadata.length() > 0) { + connection.setRequestProperty(TusProtocol.METADATA_HEADER_NAME, encodedMetadata); + } + } + + private static String finalUploadConcatValue(@NotNull List uploadURLs) { + StringBuilder value = new StringBuilder(TusProtocol.CONCATENATION_FINAL_PREFIX); + for (int index = 0; index < uploadURLs.size(); index++) { + if (index > 0) { + value.append(TusProtocol.CONCATENATION_UPLOAD_URL_SEPARATOR); + } + value.append(uploadURLs.get(index).toString()); + } + + return value.toString(); + } + private static void writeUploadCreationData( @NotNull HttpURLConnection connection, @NotNull TusUpload upload, diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 11318a44..431a8821 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -16,6 +16,14 @@ * Generated TUS protocol constants used by the runtime client. */ final class TusProtocol { + static final String CONCATENATION_FINAL_PREFIX = + "final;"; + static final String CONCATENATION_HEADER_NAME = + "Upload-Concat"; + static final String CONCATENATION_PARTIAL_VALUE = + "partial"; + static final String CONCATENATION_UPLOAD_URL_SEPARATOR = + " "; static final String CREATE_UPLOAD_METHOD = "POST"; static final String DETAILED_ERROR_CAUSE_STRING_TEMPLATE = "Error: {message}"; diff --git a/src/main/java/io/tus/java/client/TusUpload.java b/src/main/java/io/tus/java/client/TusUpload.java index 7191fcc1..41f9678b 100644 --- a/src/main/java/io/tus/java/client/TusUpload.java +++ b/src/main/java/io/tus/java/client/TusUpload.java @@ -150,6 +150,10 @@ public Map getMetadata() { * @return Encoded metadata */ public String getEncodedMetadata() { + return encodeMetadata(metadata); + } + + static String encodeMetadata(Map metadata) { if (metadata == null || metadata.size() == 0) { return ""; } diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index a38db546..f8308dc6 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -241,6 +241,79 @@ public void testCreateUploadWithDeferredLength() throws IOException, ProtocolExc assertFalse(requests[0].containsHeader("Upload-Length")); } + /** + * Verifies if partial uploads can be created using the Concatenation extension. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreatePartialUpload() throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Upload-Concat", "partial") + .withHeader("Upload-Metadata", "test d29ybGQ=") + .withHeader("Upload-Length", "5"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/part-1"))); + + Map metadata = new LinkedHashMap(); + metadata.put("test", "world"); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(5); + upload.setInputStream(new ByteArrayInputStream(new byte[5])); + upload.setMetadata(metadata); + TusUploader uploader = client.createPartialUpload(upload); + + assertEquals(new URL(mockServerURL + "/part-1"), uploader.getUploadURL()); + } + + /** + * Verifies if partial uploads can be concatenated into a final upload. + * @throws IOException if the request cannot be issued. + * @throws ProtocolException if the final upload cannot be constructed. + */ + @Test + public void testConcatenateUploads() throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader( + "Upload-Concat", + "final;" + + mockServerURL + + "/part-1 " + + mockServerURL + + "/part-2" + ) + .withHeader("Upload-Metadata", "foo aGVsbG8="))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/final"))); + + Map metadata = new LinkedHashMap(); + metadata.put("foo", "hello"); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + URL uploadURL = client.concatenateUploads(Arrays.asList( + new URL(mockServerURL + "/part-1"), + new URL(mockServerURL + "/part-2") + ), metadata); + + HttpRequest[] requests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("POST") + .withPath("/files")); + + assertEquals(new URL(mockServerURL + "/final"), uploadURL); + assertEquals(1, requests.length); + assertFalse(requests[0].containsHeader("Upload-Length")); + } + /** * Verifies if uploads can be created with the tus client through a proxy. * @throws IOException if upload data cannot be read. From 2478d898a62ed1bc16e1873b1277e52e8573aac5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 13:18:32 +0200 Subject: [PATCH 94/96] Add TUS protocol selection proof --- example/build.gradle | 6 + ...pi2DevdockTusProtocolVersionSelection.java | 103 +++++++++++ .../java/io/tus/java/client/TusClient.java | 57 +++++- .../java/io/tus/java/client/TusProtocol.java | 175 +++++++++++++++++- .../java/client/TusStartOptionValidator.java | 2 +- .../java/io/tus/java/client/TusUploader.java | 33 +++- 6 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java diff --git a/example/build.gradle b/example/build.gradle index 56a9dabb..efd98bee 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -72,6 +72,12 @@ tasks.register('api2DevdockTusParallelUploadConcat', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusProtocolVersionSelection', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusProtocolVersionSelection' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java new file mode 100644 index 00000000..972cb5b9 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java @@ -0,0 +1,103 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.util.Map; + +public final class Api2DevdockTusProtocolVersionSelection { + /** + * Run the API2 devdock TUS protocol-version selection example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadWithSelectedProtocol(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " created upload " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithSelectedProtocol(JSONObject scenario) throws Exception { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final String protocol = Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "protocol" + ); + final boolean uploadDataDuringCreation = + Api2DevdockScenario.conformanceInputBooleanOption( + conformanceScenario, + "uploadDataDuringCreation", + false + ); + if (!uploadDataDuringCreation) { + throw new IllegalArgumentException( + "Java protocol-version selection proof expects creation with upload" + ); + } + + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) { + final TusClient client = new TusClient(); + client.setProtocol(protocol); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint( + scenario.getString("scenarioId") + "-java-protocol-version-selection" + ); + upload.setMetadata(metadata); + + final TusUploader uploader = client.createUploadWithData(upload, content.length); + uploader.finish(); + + conformanceServer.assertExhausted(); + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", true); + result.put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + + return result; + } + } + + private Api2DevdockTusProtocolVersionSelection() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index 7c19dce6..3a60bc8b 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -28,6 +28,7 @@ public class TusClient { private boolean addRequestId; private TusURLStore urlStore; private Map headers; + private String protocol = TusProtocol.DEFAULT_CLIENT_PROTOCOL; private int connectTimeout = 5000; private TusRequestLifecycleHooks requestLifecycleHooks; private volatile HttpURLConnection currentConnection; @@ -169,6 +170,31 @@ public Map getHeaders() { return headers; } + /** + * Select the TUS client protocol mode used for generated protocol headers. + * + * @param protocol The protocol mode, e.g. {@code tus-v1} or {@code ietf-draft-05}. + */ + public void setProtocol(@Nullable String protocol) { + final String normalizedProtocol = TusProtocol.normalizeProtocol(protocol); + if (normalizedProtocol == null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + protocol + ); + } + + this.protocol = normalizedProtocol; + } + + /** + * Return the selected TUS client protocol mode. + * + * @return The selected protocol mode. + */ + public String getProtocol() { + return protocol; + } + /** * Enable generated request IDs for every HTTP request made by this TusClient instance. */ @@ -363,10 +389,7 @@ private TusUploader createUpload( prepareUploadCreationHeaders(connection, upload); if (bytesToUpload > 0) { - connection.setRequestProperty( - TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, - TusProtocol.UPLOAD_BODY_CONTENT_TYPE - ); + prepareUploadBodyHeaders(connection, bytesToUpload >= upload.getSize()); connection.setDoOutput(true); connection.setFixedLengthStreamingMode(bytesToUpload); } @@ -535,6 +558,30 @@ private static void prepareUploadMetadataHeaders( } } + private void prepareUploadBodyHeaders( + @NotNull HttpURLConnection connection, + boolean requestCompletesUpload + ) { + final String contentType = TusProtocol.protocolUploadBodyContentType(protocol); + if (contentType != null) { + connection.setRequestProperty( + TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + contentType + ); + } + + final String uploadCompleteHeaderName = + TusProtocol.protocolUploadCompleteHeaderName(protocol); + if (uploadCompleteHeaderName == null) { + return; + } + + connection.setRequestProperty( + uploadCompleteHeaderName, + TusProtocol.protocolUploadCompleteHeaderValue(protocol, requestCompletesUpload) + ); + } + private static String finalUploadConcatValue(@NotNull List uploadURLs) { StringBuilder value = new StringBuilder(TusProtocol.CONCATENATION_FINAL_PREFIX); for (int index = 0; index < uploadURLs.size(); index++) { @@ -814,7 +861,7 @@ public void prepareConnection(@NotNull HttpURLConnection connection) { connection.setInstanceFollowRedirects(Boolean.getBoolean("http.strictPostRedirect")); connection.setConnectTimeout(connectTimeout); - TusProtocol.prepareRequestHeaders(connection, headers, addRequestId); + TusProtocol.prepareRequestHeaders(connection, headers, addRequestId, protocol); } final void runBeforeRequest( diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 431a8821..972550c0 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -40,9 +40,15 @@ final class TusProtocol { static final String DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE = "tus: unexpected response while creating upload"; static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; + static final String DEFAULT_CLIENT_PROTOCOL = "tus-v1"; static final int DEFAULT_PARALLEL_UPLOADS = 1; static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final String PROTOCOL_TUS_V1 = "tus-v1"; + static final String PROTOCOL_IETF_DRAFT_03 = "ietf-draft-03"; + static final String PROTOCOL_IETF_DRAFT_05 = "ietf-draft-05"; + private static final Map + CLIENT_PROTOCOL_COMPATIBILITY_VERSIONS = clientProtocolCompatibilityVersions(); static final String LOCATION_HEADER_NAME = "Location"; static final String METADATA_HEADER_NAME = "Upload-Metadata"; static final int MINIMUM_PARALLEL_UPLOADS = 2; @@ -96,13 +102,62 @@ static String formatDetailedErrorMessage(String template, Map va static void prepareRequestHeaders( HttpURLConnection connection, Map customHeaders, - boolean addRequestId + boolean addRequestId, + String protocolVersion ) { - addDefaultRequestHeaders(connection); + addProtocolRequestHeaders(connection, protocolVersion); addCustomRequestHeaders(connection, customHeaders); addRequestIdHeader(connection, addRequestId); } + static boolean isSupportedProtocol(String protocolVersion) { + return clientProtocolCompatibilityVersionFor(protocolVersion) != null; + } + + static String normalizeProtocol(String protocolVersion) { + if (protocolVersion == null + || protocolVersion.length() == 0 + || DEFAULT_PROTOCOL_VERSION.equals(protocolVersion)) { + return DEFAULT_CLIENT_PROTOCOL; + } + + if (!CLIENT_PROTOCOL_COMPATIBILITY_VERSIONS.containsKey(protocolVersion)) { + return null; + } + + return protocolVersion; + } + + static String protocolUploadBodyContentType(String protocolVersion) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null) { + return null; + } + + return compatibilityVersion.uploadBodyContentType; + } + + static String protocolUploadCompleteHeaderName(String protocolVersion) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null || compatibilityVersion.uploadCompleteHeader == null) { + return null; + } + + return compatibilityVersion.uploadCompleteHeader.name; + } + + static String protocolUploadCompleteHeaderValue(String protocolVersion, boolean done) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null || compatibilityVersion.uploadCompleteHeader == null) { + return null; + } + + return compatibilityVersion.uploadCompleteHeader.value(done); + } + private static Map defaultRequestHeaders() { Map result = new LinkedHashMap(); result.put("Tus-Resumable", "1.0.0"); @@ -115,12 +170,85 @@ private static Map defaultResponseHeaders() { return Collections.unmodifiableMap(result); } - private static void addDefaultRequestHeaders(HttpURLConnection connection) { - for (Map.Entry entry : DEFAULT_REQUEST_HEADERS.entrySet()) { + private static Map + clientProtocolCompatibilityVersions() { + Map result = + new LinkedHashMap(); + result.put( + "tus-v1", + new ClientProtocolCompatibilityVersion( + stringMap(new String[][] { + { "Tus-Resumable", "1.0.0" }, + }), + stringMap(new String[][] { + { "Tus-Resumable", "1.0.0" }, + }), + "application/offset+octet-stream", + null + ) + ); + result.put( + "ietf-draft-03", + new ClientProtocolCompatibilityVersion( + stringMap(new String[][] { + { "Upload-Draft-Interop-Version", "5" }, + }), + stringMap(new String[0][0]), + null, + new UploadCompleteHeader("Upload-Complete", "?1", "?0") + ) + ); + result.put( + "ietf-draft-05", + new ClientProtocolCompatibilityVersion( + stringMap(new String[][] { + { "Upload-Draft-Interop-Version", "6" }, + }), + stringMap(new String[0][0]), + "application/partial-upload", + new UploadCompleteHeader("Upload-Complete", "?1", "?0") + ) + ); + return Collections.unmodifiableMap(result); + } + + private static Map stringMap(String[][] entries) { + Map result = new LinkedHashMap(); + for (String[] entry : entries) { + result.put(entry[0], entry[1]); + } + + return Collections.unmodifiableMap(result); + } + + private static void addProtocolRequestHeaders( + HttpURLConnection connection, + String protocolVersion + ) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null) { + throw new IllegalArgumentException( + START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + protocolVersion + ); + } + + for (Map.Entry entry : compatibilityVersion.requestHeaders.entrySet()) { connection.addRequestProperty(entry.getKey(), entry.getValue()); } } + private static ClientProtocolCompatibilityVersion clientProtocolCompatibilityVersionFor( + String protocolVersion + ) { + String normalizedProtocol = normalizeProtocol(protocolVersion); + if (normalizedProtocol == null) { + return null; + } + + return CLIENT_PROTOCOL_COMPATIBILITY_VERSIONS.get(normalizedProtocol); + } + private static void addCustomRequestHeaders( HttpURLConnection connection, Map customHeaders @@ -141,4 +269,43 @@ private static void addRequestIdHeader(HttpURLConnection connection, boolean add connection.setRequestProperty(REQUEST_ID_HEADER_NAME, UUID.randomUUID().toString()); } + + private static final class ClientProtocolCompatibilityVersion { + private final Map requestHeaders; + private final Map responseHeaders; + private final String uploadBodyContentType; + private final UploadCompleteHeader uploadCompleteHeader; + + ClientProtocolCompatibilityVersion( + Map requestHeaders, + Map responseHeaders, + String uploadBodyContentType, + UploadCompleteHeader uploadCompleteHeader + ) { + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + this.uploadBodyContentType = uploadBodyContentType; + this.uploadCompleteHeader = uploadCompleteHeader; + } + } + + private static final class UploadCompleteHeader { + private final String name; + private final String completeValue; + private final String incompleteValue; + + UploadCompleteHeader(String name, String completeValue, String incompleteValue) { + this.name = name; + this.completeValue = completeValue; + this.incompleteValue = incompleteValue; + } + + private String value(boolean done) { + if (done) { + return completeValue; + } + + return incompleteValue; + } + } } diff --git a/src/main/java/io/tus/java/client/TusStartOptionValidator.java b/src/main/java/io/tus/java/client/TusStartOptionValidator.java index 75277f6f..8e7d35ae 100644 --- a/src/main/java/io/tus/java/client/TusStartOptionValidator.java +++ b/src/main/java/io/tus/java/client/TusStartOptionValidator.java @@ -12,7 +12,7 @@ static void validate(TusStartOptions options) { ); } - if (!TusProtocol.DEFAULT_PROTOCOL_VERSION.equals(options.getProtocol())) { + if (!TusProtocol.isSupportedProtocol(options.getProtocol())) { throw new IllegalArgumentException( TusProtocol.START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + options.getProtocol() diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index e5915c22..a03841af 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -129,10 +129,7 @@ private void openConnection() throws IOException, ProtocolException { connection.setRequestProperty(TusProtocol.UPLOAD_LENGTH_HEADER_NAME, Long.toString(upload.getSize())); requestDeclaresUploadLength = true; } - connection.setRequestProperty( - TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, - TusProtocol.UPLOAD_BODY_CONTENT_TYPE - ); + prepareUploadBodyHeaders(connection); connection.setRequestProperty("Expect", "100-continue"); try { @@ -236,6 +233,34 @@ private boolean shouldDeclareUploadLength() { return offset + requestPayloadSize >= upload.getSize(); } + private void prepareUploadBodyHeaders(HttpURLConnection connection) { + final String contentType = TusProtocol.protocolUploadBodyContentType(client.getProtocol()); + if (contentType != null) { + connection.setRequestProperty( + TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + contentType + ); + } + + final String uploadCompleteHeaderName = + TusProtocol.protocolUploadCompleteHeaderName(client.getProtocol()); + if (uploadCompleteHeaderName == null) { + return; + } + + connection.setRequestProperty( + uploadCompleteHeaderName, + TusProtocol.protocolUploadCompleteHeaderValue( + client.getProtocol(), + requestCompletesUpload() + ) + ); + } + + private boolean requestCompletesUpload() { + return upload.getSize() > 0 && offset + requestPayloadSize >= upload.getSize(); + } + /** * Set the listener used for upload progress events. * From d5526e5973ea29e56fe73265fd509a1864a01f66 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 13:31:51 +0200 Subject: [PATCH 95/96] Satisfy generated protocol checkstyle --- src/main/java/io/tus/java/client/TusProtocol.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java index 972550c0..96499c42 100644 --- a/src/main/java/io/tus/java/client/TusProtocol.java +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -178,10 +178,10 @@ private static Map defaultResponseHeaders() { "tus-v1", new ClientProtocolCompatibilityVersion( stringMap(new String[][] { - { "Tus-Resumable", "1.0.0" }, + {"Tus-Resumable", "1.0.0"}, }), stringMap(new String[][] { - { "Tus-Resumable", "1.0.0" }, + {"Tus-Resumable", "1.0.0"}, }), "application/offset+octet-stream", null @@ -191,7 +191,7 @@ private static Map defaultResponseHeaders() { "ietf-draft-03", new ClientProtocolCompatibilityVersion( stringMap(new String[][] { - { "Upload-Draft-Interop-Version", "5" }, + {"Upload-Draft-Interop-Version", "5"}, }), stringMap(new String[0][0]), null, @@ -202,7 +202,7 @@ private static Map defaultResponseHeaders() { "ietf-draft-05", new ClientProtocolCompatibilityVersion( stringMap(new String[][] { - { "Upload-Draft-Interop-Version", "6" }, + {"Upload-Draft-Interop-Version", "6"}, }), stringMap(new String[0][0]), "application/partial-upload", From 421e4dd4e2cc40dbb5c6237b3d2bd030e84ab7c2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 17:14:51 +0200 Subject: [PATCH 96/96] Add TUS node path input proof --- example/build.gradle | 6 + .../tus/java/example/Api2DevdockScenario.java | 13 +- .../Api2DevdockTusConformanceServer.java | 2 +- .../Api2DevdockTusNodePathInputSource.java | 123 ++++++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java diff --git a/example/build.gradle b/example/build.gradle index efd98bee..22fce4ff 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -78,6 +78,12 @@ tasks.register('api2DevdockTusProtocolVersionSelection', JavaExec) { workingDir = rootProject.projectDir } +tasks.register('api2DevdockTusNodePathInputSource', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusNodePathInputSource' + workingDir = rootProject.projectDir +} + tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java index cc054aac..0c85029e 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -99,14 +99,21 @@ static JSONObject conformanceScenario(JSONObject scenario) { static byte[] conformanceInputSourceBytes(JSONObject conformanceScenario) { final JSONObject inputSource = conformanceScenario.getJSONObject("inputSource"); - final String kind = inputSource.getString("kind"); - if (!"blob".equals(kind)) { - throw new IllegalArgumentException("unsupported conformance input source kind " + kind); + if (!inputSource.has("content")) { + throw new IllegalArgumentException( + "unsupported conformance input source kind " + + inputSource.getString("kind") + + " without content" + ); } return inputSource.getString("content").getBytes(StandardCharsets.UTF_8); } + static String conformanceInputSourceKind(JSONObject conformanceScenario) { + return conformanceScenario.getJSONObject("inputSource").getString("kind"); + } + static Map conformanceInputStringMapOption( JSONObject conformanceScenario, String key diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java index c1477f69..cf4b4adf 100644 --- a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java @@ -200,7 +200,7 @@ private JSONArray observedIntegers(Integer[] values) { private static byte[] inputSourceContent(JSONObject conformanceScenario) { final JSONObject inputSource = conformanceScenario.optJSONObject("inputSource"); - if (inputSource == null || !"blob".equals(inputSource.optString("kind"))) { + if (inputSource == null || !inputSource.has("content")) { return null; } diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java new file mode 100644 index 00000000..2c601e60 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java @@ -0,0 +1,123 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; + +public final class Api2DevdockTusNodePathInputSource { + /** + * Run the API2 devdock TUS node path input source example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = uploadWithNodePathInputSource(conformanceScenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " read " + + Api2DevdockScenario.conformanceInputSourceKind(conformanceScenario) + + " for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithNodePathInputSource(JSONObject conformanceScenario) + throws IOException, ProtocolException { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final String inputKind = Api2DevdockScenario.conformanceInputSourceKind(conformanceScenario); + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + ) + ); + final JSONArray events = new JSONArray(); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final File source = File.createTempFile("api2-java-tus-node-path-input", ".bin"); + try { + Files.write(source.toPath(), content); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final TusUpload upload = new TusUpload(source); + upload.setMetadata( + Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ) + ); + + events.put(new JSONObject() + .put("inputKind", inputKind) + .put("kind", "source-open") + .put("size", upload.getSize())); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(content.length); + boolean uploadFinished = false; + try { + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadFinished = true; + } catch (IOException | ProtocolException error) { + throw new IOException( + "node path input source conformance failed: " + + conformanceServer.errorSummary(), + error + ); + } finally { + if (uploadFinished) { + events.put(new JSONObject().put("kind", "success")); + } + events.put(new JSONObject().put("kind", "source-close")); + } + + if (uploader.getUploadURL() == null) { + throw new IllegalStateException( + "node path input source upload did not expose a URL" + ); + } + + conformanceServer.assertExhausted(); + return conformanceServer.result() + .put("events", events) + .put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + } finally { + Files.deleteIfExists(source.toPath()); + } + } + } + + private Api2DevdockTusNodePathInputSource() { + throw new IllegalStateException("Utility class"); + } +}