Purpose: Ensure the same proto definitions produce semantically identical JSON across go-http, go-client, ts-client, and OpenAPI. The server must serialize what clients expect, and OpenAPI must document what both produce.
Output: Cross-generator consistency tests that verify JSON output matches between generators and that OpenAPI schemas accurately reflect the serialization behavior.
<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>
Existing consistency test patterns from Phase 4
@internal/httpgen/encoding_consistency_test.go @internal/httpgen/enum_encoding_consistency_test.go
package httpgen_test
import (
"bytes"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestNullableConsistencyGoHTTPvsGoClient verifies that go-http and go-client
// generate identical MarshalJSON/UnmarshalJSON code for nullable fields.
func TestNullableConsistencyGoHTTPvsGoClient(t *testing.T) {
// Generate nullable output for both generators
httpGenOutput := generateHTTPNullableOutput(t)
clientGenOutput := generateClientNullableOutput(t)
// Normalize package names and comments for comparison
normalizedHTTP := normalizeGeneratorOutput(httpGenOutput, "httpgen")
normalizedClient := normalizeGeneratorOutput(clientGenOutput, "clientgen")
// Compare the core MarshalJSON logic
require.Equal(t, normalizedHTTP, normalizedClient,
"go-http and go-client nullable implementations must be identical")
}
// TestNullableConsistencyTypeScript verifies TypeScript types match Go nullable behavior.
func TestNullableConsistencyTypeScript(t *testing.T) {
// For nullable=true fields:
// - Go emits null when unset
// - TypeScript must use T | null (not T?)
tsOutput := generateTSNullableOutput(t)
// Verify nullable fields use union type, not optional
// Example: middleName: string | null (not middleName?: string)
require.Contains(t, tsOutput, "| null",
"TypeScript must use T | null for nullable fields")
// Verify pattern: fieldName: type | null
nullablePattern := regexp.MustCompile(`\w+:\s+\w+\s+\|\s+null`)
require.True(t, nullablePattern.MatchString(tsOutput),
"TypeScript nullable fields must use 'fieldName: T | null' pattern")
}
// TestNullableConsistencyOpenAPI verifies OpenAPI schemas match Go nullable behavior.
func TestNullableConsistencyOpenAPI(t *testing.T) {
// For nullable=true fields:
// - Go emits null when unset
// - OpenAPI 3.1 must use type: ["T", "null"] array syntax
openapiOutput := generateOpenAPINullableOutput(t)
// Verify OpenAPI uses type array for nullable fields
// Example: type: ["string", "null"]
require.Contains(t, openapiOutput, `"null"`,
"OpenAPI must include null in type array for nullable fields")
// Verify it's in array format, not the deprecated nullable: true
require.NotContains(t, openapiOutput, "nullable: true",
"OpenAPI must NOT use deprecated nullable: true syntax")
}
// TestNullableSerializationRoundTrip verifies nullable fields roundtrip correctly.
func TestNullableSerializationRoundTrip(t *testing.T) {
testCases := []struct {
name string
input string // JSON input
expected string // Expected JSON output
}{
{
name: "unset nullable field emits null",
input: `{}`,
expected: `"middleName":null`, // nullable=true emits null when unset
},
{
name: "set nullable field emits value",
input: `{"middleName":"John"}`,
expected: `"middleName":"John"`,
},
{
name: "explicit null on nullable field roundtrips",
input: `{"middleName":null}`,
expected: `"middleName":null`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test with generated code
output := marshalUnmarshalNullable(t, tc.input)
require.Contains(t, output, tc.expected,
"Nullable field serialization must match expected output")
})
}
}
// Helper functions to generate output from each generator
// These use the test infrastructure to run generators on nullable.proto
func generateHTTPNullableOutput(t *testing.T) string {
t.Helper()
// Use existing golden file test infrastructure
// Read the generated _nullable.pb.go output
return readGeneratedFile(t, "internal/httpgen/testdata", "nullable", "_nullable.pb.go")
}
func generateClientNullableOutput(t *testing.T) string {
t.Helper()
return readGeneratedFile(t, "internal/clientgen/testdata", "nullable", "_nullable.pb.go")
}
func generateTSNullableOutput(t *testing.T) string {
t.Helper()
return readGeneratedFile(t, "internal/tsclientgen/testdata", "nullable", ".ts")
}
func generateOpenAPINullableOutput(t *testing.T) string {
t.Helper()
return readGeneratedFile(t, "internal/openapiv3/testdata", "nullable", ".openapi.yaml")
}
func normalizeGeneratorOutput(output, generatorName string) string {
// Remove generator-specific package names
output = strings.ReplaceAll(output, "package httpgen", "package NORMALIZED")
output = strings.ReplaceAll(output, "package clientgen", "package NORMALIZED")
// Remove file path comments that differ
lines := strings.Split(output, "\n")
var normalized []string
for _, line := range lines {
if strings.HasPrefix(line, "// Code generated") {
continue
}
normalized = append(normalized, line)
}
return strings.Join(normalized, "\n")
}
func readGeneratedFile(t *testing.T, testdataDir, protoName, suffix string) string {
t.Helper()
// Implementation: read the generated golden file
// This will be filled in based on the test infrastructure
return ""
}
func marshalUnmarshalNullable(t *testing.T, input string) string {
t.Helper()
// Implementation: use generated code to marshal/unmarshal
return ""
}The test file should:
- Compare generated MarshalJSON between go-http and go-client (byte-level after normalization)
- Verify TypeScript uses
T | nullsyntax - Verify OpenAPI uses type array
["T", "null"]syntax - Test serialization roundtrip behavior
Adapt the helper functions to use the actual test infrastructure and golden file patterns from the codebase.
go test -v -run TestNullableConsistency ./internal/httpgen/...
make lint-fixpackage httpgen_test
import (
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestEmptyBehaviorConsistencyGoHTTPvsGoClient verifies go-http and go-client
// generate identical MarshalJSON code for empty_behavior fields.
func TestEmptyBehaviorConsistencyGoHTTPvsGoClient(t *testing.T) {
httpGenOutput := generateHTTPEmptyBehaviorOutput(t)
clientGenOutput := generateClientEmptyBehaviorOutput(t)
normalizedHTTP := normalizeGeneratorOutput(httpGenOutput, "httpgen")
normalizedClient := normalizeGeneratorOutput(clientGenOutput, "clientgen")
require.Equal(t, normalizedHTTP, normalizedClient,
"go-http and go-client empty_behavior implementations must be identical")
}
// TestEmptyBehaviorConsistencyOpenAPI verifies OpenAPI schemas match Go behavior.
func TestEmptyBehaviorConsistencyOpenAPI(t *testing.T) {
openapiOutput := generateOpenAPIEmptyBehaviorOutput(t)
// For empty_behavior=NULL fields:
// - Go emits null when message is empty
// - OpenAPI must use oneOf with null type
// Verify oneOf pattern for NULL fields
require.Contains(t, openapiOutput, "oneOf",
"OpenAPI must use oneOf for empty_behavior=NULL fields")
// Verify null type in oneOf
nullInOneOfPattern := regexp.MustCompile(`oneOf.*\n.*\$ref.*\n.*type.*null`)
require.True(t, nullInOneOfPattern.MatchString(openapiOutput) ||
strings.Contains(openapiOutput, `"null"`),
"OpenAPI oneOf must include null type for empty_behavior=NULL")
}
// TestEmptyBehaviorSerializationRoundTrip verifies empty_behavior fields roundtrip correctly.
func TestEmptyBehaviorSerializationRoundTrip(t *testing.T) {
testCases := []struct {
name string
behavior string
input string // JSON input
expected string // Expected pattern in output
absent string // Pattern that should NOT be in output
}{
{
name: "NULL empty message becomes null",
behavior: "NULL",
input: `{"id":"1","metadataNull":{}}`,
expected: `"metadataNull":null`,
},
{
name: "OMIT empty message is removed",
behavior: "OMIT",
input: `{"id":"1","metadataOmit":{}}`,
absent: `"metadataOmit"`,
},
{
name: "PRESERVE empty message stays as {}",
behavior: "PRESERVE",
input: `{"id":"1","metadataPreserve":{}}`,
expected: `"metadataPreserve":{}`,
},
{
name: "NULL non-empty message stays as object",
behavior: "NULL",
input: `{"id":"1","metadataNull":{"key":"k","value":"v"}}`,
expected: `"metadataNull":{"key":"k"`,
},
{
name: "NULL accepts null on unmarshal",
behavior: "NULL",
input: `{"id":"1","metadataNull":null}`,
expected: `"metadataNull":null`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
output := marshalUnmarshalEmptyBehavior(t, tc.input)
if tc.expected != "" {
require.Contains(t, output, tc.expected,
"Empty behavior %s must produce expected output", tc.behavior)
}
if tc.absent != "" {
require.NotContains(t, output, tc.absent,
"Empty behavior %s must NOT contain absent pattern", tc.behavior)
}
})
}
}
// TestEmptyMessageDetection verifies proto.Size() == 0 correctly detects empty.
func TestEmptyMessageDetection(t *testing.T) {
testCases := []struct {
name string
message string // JSON representation
isEmpty bool
}{
{
name: "all defaults is empty",
message: `{}`,
isEmpty: true,
},
{
name: "zero string is empty",
message: `{"key":""}`,
isEmpty: true,
},
{
name: "zero int is empty",
message: `{"count":0}`,
isEmpty: true,
},
{
name: "non-zero string is not empty",
message: `{"key":"value"}`,
isEmpty: false,
},
{
name: "non-zero int is not empty",
message: `{"count":1}`,
isEmpty: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
isEmpty := isMessageEmpty(t, tc.message)
require.Equal(t, tc.isEmpty, isEmpty,
"Empty detection must match expected for %s", tc.message)
})
}
}
func generateHTTPEmptyBehaviorOutput(t *testing.T) string {
t.Helper()
return readGeneratedFile(t, "internal/httpgen/testdata", "empty_behavior", "_empty_behavior.pb.go")
}
func generateClientEmptyBehaviorOutput(t *testing.T) string {
t.Helper()
return readGeneratedFile(t, "internal/clientgen/testdata", "empty_behavior", "_empty_behavior.pb.go")
}
func generateOpenAPIEmptyBehaviorOutput(t *testing.T) string {
t.Helper()
return readGeneratedFile(t, "internal/openapiv3/testdata", "empty_behavior", ".openapi.yaml")
}
func marshalUnmarshalEmptyBehavior(t *testing.T, input string) string {
t.Helper()
// Implementation: use generated code to marshal/unmarshal
return ""
}
func isMessageEmpty(t *testing.T, jsonMessage string) bool {
t.Helper()
// Implementation: unmarshal and check proto.Size() == 0
return false
}The test file should:
- Compare generated MarshalJSON between go-http and go-client
- Verify OpenAPI uses oneOf for NULL fields
- Test serialization behavior for NULL, OMIT, PRESERVE modes
- Test proto.Size() == 0 empty detection matches expectations
Adapt helper functions to use actual test infrastructure.
go test -v -run TestEmptyBehaviorConsistency ./internal/httpgen/...
make lint-fix- All existing tests still pass (zero regression)
- All new consistency tests pass
- Golden files are up to date
- Lint is clean
# Full test suite
go test -v ./...
# Specific generator tests
go test -v ./internal/httpgen/... ./internal/clientgen/... ./internal/tsclientgen/... ./internal/openapiv3/...
# Golden file tests
go test -v -run TestExhaustiveGoldenFiles ./internal/...
# Consistency tests
go test -v -run TestNullableConsistency ./internal/httpgen/...
go test -v -run TestEmptyBehaviorConsistency ./internal/httpgen/...
# Lint check
make lint-fixVerify that:
- nullable.proto and empty_behavior.proto golden files exist for all generators
- Generated code matches expected golden output
- Cross-generator consistency verified
If any test fails, debug and fix the implementation in plans 02/03.
go test -v ./...
make lint-fixAll tests pass, lint clean, no regressions.
- All 4 generators produce consistent output for nullable fields
- All 4 generators produce consistent output for empty_behavior fields
- Cross-generator consistency tests pass
- All existing tests pass (zero regression)
- Lint clean
<success_criteria>
- go-http and go-client produce byte-identical JSON for nullable fields
- go-http and go-client produce byte-identical JSON for empty_behavior fields
- TypeScript T | null types correctly represent Go's null emission
- OpenAPI type: ["T", "null"] correctly represents Go's null emission
- OpenAPI oneOf with null type correctly represents empty_behavior=NULL
- All serialization roundtrip tests pass
- proto.Size() == 0 empty detection works correctly
- Zero regressions in existing tests </success_criteria>
