Files
warmbly/internal/models/event_schema_process_test.go
Matthew Meszaros 746dd40469 feat: strengthen the Avro round-trip tests after a cutover they failed to catch (#536)
* feat: compare the decoded event body's fields and not only its type, fill arrays so every uuid carries a real value instead of the zero one a codec could drop unnoticed, and decode once in a process that has never encoded, because production is four processes and one of them only ever reads what another wrote

* feat: register the union body types at package load instead of on first schema build, which is what a process that only ever decodes never reached, so every worker command arrived as a map keyed by its branch name, went through the JSON fallback, and became a struct with every field zero and no error anywhere
2026-09-15 20:25:21 -07:00

90 lines
3.0 KiB
Go

package models
import (
"encoding/base64"
"os"
"os/exec"
"reflect"
"strings"
"testing"
"github.com/hamba/avro/v2"
)
// Production is four processes, and one of them only ever decodes what another
// encoded. Every other test here encodes and decodes in one process, which
// registers the union's Go types as a side effect of building the schema, so
// the decode half was never tested from a cold start.
//
// That gap is why an Avro cutover failed: the worker decoded ADD_EMAIL into a
// body with every field empty, reported "Unsupported email provider" for all 39
// mailboxes, and never logged a decode error. Silence, not a failure.
//
// So the decoder here is a child process that has never encoded anything.
const (
decodeHelperEnv = "WARMBLY_DECODE_HELPER"
decodeSchemaEnv = "WARMBLY_DECODE_SCHEMA"
)
func TestDecodeInAProcessThatHasNeverEncoded(t *testing.T) {
if payload := os.Getenv(decodeHelperEnv); payload != "" {
runDecodeHelper(t, payload)
return
}
schema := WorkerEvent{}.Schema()
in := WorkerEvent{Type: WorkerEventTypeAddEmail, Body: sample(WorkerEventBodies[WorkerEventTypeAddEmail])}
raw, err := avro.Marshal(schema, in)
if err != nil {
t.Fatalf("encode: %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run", "TestDecodeInAProcessThatHasNeverEncoded", "-test.v")
cmd.Env = append(os.Environ(),
decodeHelperEnv+"="+base64.StdEncoding.EncodeToString(raw),
// The schema travels as JSON, the way a registry hands one back. The
// child must not build its own, because building one registers the
// union's Go types and that is the side effect under test.
decodeSchemaEnv+"="+schema.String(),
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("decoder process failed: %v\n%s", err, out)
}
if !strings.Contains(string(out), "DECODED-OK") {
t.Fatalf("decoder process did not confirm the body type:\n%s", out)
}
}
// runDecodeHelper is the child, and the whole point of it is what it does not
// do: it never calls Schema(). The codec's decode path does not either, because
// it parses the writer's schema out of the registry, so a process that has only
// ever consumed has nothing that would have run avro.Register.
func runDecodeHelper(t *testing.T, encoded string) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("helper: payload: %v", err)
}
schema, err := avro.Parse(os.Getenv(decodeSchemaEnv))
if err != nil {
t.Fatalf("helper: schema: %v", err)
}
var out WorkerEvent
if err := avro.Unmarshal(schema, raw, &out); err != nil {
t.Fatalf("helper: decode: %v", err)
}
want := reflect.TypeOf(sample(WorkerEventBodies[WorkerEventTypeAddEmail]))
if got := reflect.TypeOf(out.Body); got != want {
t.Fatalf("helper: body decoded as %v, want %v", got, want)
}
body, ok := out.Body.(*AddWorkerEmail)
if !ok {
t.Fatalf("helper: body is %T", out.Body)
}
if body.Type == "" || body.ID.String() == "00000000-0000-0000-0000-000000000000" {
t.Fatalf("helper: body decoded empty: provider=%q id=%s", body.Type, body.ID)
}
t.Log("DECODED-OK")
}