From 9db4fa3c5da2af6736082518ad903df5133ca735 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 16:06:27 -0400 Subject: [PATCH] test(services): round-trip generated protos through the wire codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated classes arrive precompiled in `com.flipcash:flipcash2-client-protocol` and `com.flipcash:ocp-client-protocol`, built against the protobuf version those repos pin, while the app resolves the protobuf runtime from its own version catalog. Nothing asserts the two agree — the `RuntimeVersion` check that guards full gencode is not emitted for lite. The existing suite builds proto messages and reads fields back, which exercises the accessors but never the encoder or the parser. There is no `parseFrom` anywhere under `services/*/src/test`, so a codec regression from a runtime/gencode skew would pass unnoticed. Each module gets five round-trips covering the paths such a skew would break: nested and repeated messages, enums, varints at their boundaries, fixed-width floats and doubles, non-ASCII strings, bytes across the full 0-255 range, maps, oneofs, well-known types, and unknown-field retention. Truncating the encoded bytes fails all ten, so they are not passing vacuously. `:services:flipcash` and `:services:opencode` are both in `unitTestPaths`, so `flipcashTestDebug` already picks these up and every future protobuf or client-protocol bump runs them. --- .../internal/proto/ProtoWireFormatTest.kt | 117 +++++++++++++ .../internal/proto/ProtoWireFormatTest.kt | 159 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/proto/ProtoWireFormatTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/proto/ProtoWireFormatTest.kt diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/proto/ProtoWireFormatTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/proto/ProtoWireFormatTest.kt new file mode 100644 index 0000000000..c59952b90b --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/proto/ProtoWireFormatTest.kt @@ -0,0 +1,117 @@ +package com.flipcash.services.internal.proto + +import com.codeinc.flipcash.gen.blob.v1.Model.UploadTarget +import com.codeinc.flipcash.gen.chat.v1.Model +import com.codeinc.flipcash.gen.common.v1.Common +import com.google.protobuf.ByteString +import com.google.protobuf.Empty +import com.google.protobuf.Timestamp +import org.junit.Test +import kotlin.test.assertEquals +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel + +/** + * Round-trips generated messages through the protobuf wire codec. + * + * The generated classes arrive precompiled in `com.flipcash:flipcash2-client-protocol`, built + * against the protobuf version that repo pinned, while the app resolves the protobuf runtime from + * its own version catalog. The two move independently, and nothing asserts they agree — the + * `RuntimeVersion` check that guards full gencode is not emitted for lite. The rest of the suite + * builds proto messages and reads fields back, which exercises the accessors but never the encoder + * or the parser, so a codec regression would pass unnoticed. + * + * These cover the paths that a runtime/gencode skew would break: nested and repeated messages, + * enums, varints at their boundaries, non-ASCII strings, bytes, maps, oneofs, well-known types, + * and unknown-field retention. + */ +class ProtoWireFormatTest { + + @Test + fun `round-trips nested, repeated, enum and scalar fields`() { + val original = Model.Metadata.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(bytes(32) { it }).build()) + .setType(Model.ChatType.GROUP) + .addMembers(member(1)) + .addMembers(member(2)) + .setLastActivity( + Timestamp.newBuilder().setSeconds(1_764_000_000L).setNanos(123_456_789).build() + ) + .setLatestEventSequence(Long.MAX_VALUE) + .setIsHidden(true) + .setTitle("café ☕ 🧊") + .build() + + val decoded = Model.Metadata.parseFrom(original.toByteArray()) + + assertEquals(original, decoded) + // Asserted individually so a broken equals() cannot make the check above vacuous. + assertEquals(Model.ChatType.GROUP, decoded.type) + assertEquals(2, decoded.membersCount) + assertEquals(Long.MAX_VALUE, decoded.latestEventSequence) + assertEquals("café ☕ 🧊", decoded.title) + assertEquals(123_456_789, decoded.lastActivity.nanos) + } + + @Test + fun `round-trips a oneof and keeps the case that was set`() { + val original = MessagingModel.Content.newBuilder() + .setText(MessagingModel.TextContent.newBuilder().setText("hello").build()) + .build() + + val decoded = MessagingModel.Content.parseFrom(original.toByteArray()) + + assertEquals(original, decoded) + assertEquals(MessagingModel.Content.TypeCase.TEXT, decoded.typeCase) + assertEquals("hello", decoded.text.text) + } + + @Test + fun `round-trips map fields`() { + val original = UploadTarget.newBuilder() + .setMethod(UploadTarget.Method.PUT) + .setUrl("https://example.invalid/upload") + .putHeaders("Content-Type", "image/png") + .putHeaders("X-Trace", "abc123") + .putFormFields("key", "value") + .build() + + val decoded = UploadTarget.parseFrom(original.toByteArray()) + + assertEquals(original, decoded) + assertEquals(2, decoded.headersCount) + assertEquals("image/png", decoded.headersMap["Content-Type"]) + assertEquals("value", decoded.formFieldsMap["key"]) + } + + @Test + fun `round-trips bytes across the full byte range`() { + val payload = bytes(256) { it } + val original = Common.PublicKey.newBuilder().setValue(payload).build() + + val decoded = Common.PublicKey.parseFrom(original.toByteArray()) + + assertEquals(payload, decoded.value) + } + + @Test + fun `preserves unknown fields when re-encoding`() { + val original = Model.Metadata.newBuilder() + .setType(Model.ChatType.TIP_DM) + .setLatestEventSequence(42L) + .setTitle("forward compatible") + .build() + + // Empty declares no fields, so every field lands in the unknown-field set. Re-encoding has + // to emit them again for a client to survive a server that is ahead of it. + val reEncoded = Empty.parseFrom(original.toByteArray()).toByteArray() + + assertEquals(original, Model.Metadata.parseFrom(reEncoded)) + } + + private fun member(seed: Int) = Model.Member.newBuilder() + .setUserId(Common.UserId.newBuilder().setValue(bytes(32) { seed }).build()) + .build() + + private fun bytes(size: Int, value: (Int) -> Int) = + ByteString.copyFrom(ByteArray(size) { value(it).toByte() }) +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/proto/ProtoWireFormatTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/proto/ProtoWireFormatTest.kt new file mode 100644 index 0000000000..ad5429fd9f --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/proto/ProtoWireFormatTest.kt @@ -0,0 +1,159 @@ +package com.getcode.opencode.internal.proto + +import com.codeinc.opencode.gen.account.v1.OcpAccountService.TokenAccountInfo +import com.codeinc.opencode.gen.common.v1.Model +import com.codeinc.opencode.gen.currency.v1.OcpCurrencyService.Mint +import com.codeinc.opencode.gen.currency.v1.OcpCurrencyService.SocialLink +import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService.GetLimitsResponse +import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService.SendLimit +import com.codeinc.opencode.gen.transaction.v1.OcpTransactionService.SubmitIntentResponse +import com.google.protobuf.ByteString +import com.google.protobuf.Empty +import com.google.protobuf.Timestamp +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Round-trips generated messages through the protobuf wire codec. + * + * The mirror of the same test in `:services:flipcash`, covering the other generated artifact. The + * classes arrive precompiled in `com.flipcash:ocp-client-protocol`, built against the protobuf + * version that repo pinned, while the app resolves the protobuf runtime from its own version + * catalog. The two move independently, and nothing asserts they agree — the `RuntimeVersion` check + * that guards full gencode is not emitted for lite. The rest of the suite builds proto messages and + * reads fields back, which exercises the accessors but never the encoder or the parser, so a codec + * regression would pass unnoticed. + * + * These cover the paths that a runtime/gencode skew would break: nested and repeated messages, + * enums, varints at their boundaries, fixed-width floats and doubles, non-ASCII strings, bytes, + * maps, oneofs, well-known types, and unknown-field retention. + */ +class ProtoWireFormatTest { + + @Test + fun `round-trips nested, repeated, enum and scalar fields`() { + val original = TokenAccountInfo.newBuilder() + .setAddress(solanaAccountId(1)) + .setOwner(solanaAccountId(2)) + .setAccountType(Model.AccountType.POOL) + .setManagementState(TokenAccountInfo.ManagementState.MANAGEMENT_STATE_LOCKED) + .setIndex(Long.MAX_VALUE) + .setBalance(1_000_000_000L) + .setUsdCostBasis(1234.5678) + .setIsGiftCardIssuer(true) + .setCreatedAt( + Timestamp.newBuilder().setSeconds(1_764_000_000L).setNanos(123_456_789).build() + ) + .setMintMetadata( + Mint.newBuilder() + .setAddress(solanaAccountId(3)) + .setDecimals(9) + .setName("café ☕ 🧊") + .setSymbol("OCP") + .addSocialLinks( + SocialLink.newBuilder() + .setX(SocialLink.X.newBuilder().setUsername("opencode").build()) + .build() + ) + .addSocialLinks( + SocialLink.newBuilder() + .setWebsite( + SocialLink.Website.newBuilder() + .setUrl("https://example.invalid") + .build() + ) + .build() + ) + .build() + ) + .build() + + val decoded = TokenAccountInfo.parseFrom(original.toByteArray()) + + assertEquals(original, decoded) + // Asserted individually so a broken equals() cannot make the check above vacuous. + assertEquals(Model.AccountType.POOL, decoded.accountType) + assertEquals( + TokenAccountInfo.ManagementState.MANAGEMENT_STATE_LOCKED, + decoded.managementState + ) + assertEquals(Long.MAX_VALUE, decoded.index) + assertEquals(1234.5678, decoded.usdCostBasis, 0.0) + assertEquals(123_456_789, decoded.createdAt.nanos) + assertEquals("café ☕ 🧊", decoded.mintMetadata.name) + assertEquals(2, decoded.mintMetadata.socialLinksCount) + assertEquals("opencode", decoded.mintMetadata.getSocialLinks(0).x.username) + } + + @Test + fun `round-trips a oneof and keeps the case that was set`() { + val original = SubmitIntentResponse.newBuilder() + .setSuccess( + SubmitIntentResponse.Success.newBuilder() + .setCode(SubmitIntentResponse.Success.Code.OK) + .build() + ) + .build() + + val decoded = SubmitIntentResponse.parseFrom(original.toByteArray()) + + assertEquals(original, decoded) + assertEquals(SubmitIntentResponse.ResponseCase.SUCCESS, decoded.responseCase) + assertEquals(SubmitIntentResponse.Success.Code.OK, decoded.success.code) + } + + @Test + fun `round-trips map fields`() { + val original = GetLimitsResponse.newBuilder() + .setResult(GetLimitsResponse.Result.OK) + .setUsdTransacted(42.5) + .putSendLimitsByCurrency("usd", sendLimit(250.0f, 1000.0f)) + .putSendLimitsByCurrency("eur", sendLimit(200.0f, 800.0f)) + .build() + + val decoded = GetLimitsResponse.parseFrom(original.toByteArray()) + + assertEquals(original, decoded) + assertEquals(2, decoded.sendLimitsByCurrencyCount) + assertEquals(1000.0f, decoded.sendLimitsByCurrencyMap.getValue("usd").maxPerDay, 0.0f) + assertEquals(200.0f, decoded.sendLimitsByCurrencyMap.getValue("eur").maxPerTransaction, 0.0f) + } + + @Test + fun `round-trips bytes across the full byte range`() { + val payload = bytes(256) { it } + val original = Model.SolanaAccountId.newBuilder().setValue(payload).build() + + val decoded = Model.SolanaAccountId.parseFrom(original.toByteArray()) + + assertEquals(payload, decoded.value) + } + + @Test + fun `preserves unknown fields when re-encoding`() { + val original = Mint.newBuilder() + .setDecimals(9) + .setName("forward compatible") + .setSymbol("OCP") + .build() + + // Empty declares no fields, so every field lands in the unknown-field set. Re-encoding has + // to emit them again for a client to survive a server that is ahead of it. + val reEncoded = Empty.parseFrom(original.toByteArray()).toByteArray() + + assertEquals(original, Mint.parseFrom(reEncoded)) + } + + private fun sendLimit(perTransaction: Float, perDay: Float) = SendLimit.newBuilder() + .setNextTransaction(perTransaction) + .setMaxPerTransaction(perTransaction) + .setMaxPerDay(perDay) + .build() + + private fun solanaAccountId(seed: Int) = Model.SolanaAccountId.newBuilder() + .setValue(bytes(32) { seed }) + .build() + + private fun bytes(size: Int, value: (Int) -> Int) = + ByteString.copyFrom(ByteArray(size) { value(it).toByte() }) +}