Add typed artifact codec foundation
This commit is contained in:
307
internal/framework/pipeline/artifact_codec_registry_test.go
Normal file
307
internal/framework/pipeline/artifact_codec_registry_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type codecNotes struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
type codecScore struct {
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
type codecNotesAlias codecNotes
|
||||
|
||||
type testArtifactCodec[T any] struct {
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
}
|
||||
|
||||
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind }
|
||||
func (c testArtifactCodec[T]) Schema() contracts.ArtifactSchema { return c.schema }
|
||||
func (c testArtifactCodec[T]) MediaType() string { return c.mediaType }
|
||||
func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) }
|
||||
func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) }
|
||||
|
||||
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, notesCodec()); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec(notes) error = %v, want nil", err)
|
||||
}
|
||||
if err := RegisterArtifactCodec(registry, scoreCodec()); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec(score) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if got, want := registry.RegisteredKinds(), []contracts.ArtifactKind{"test/notes", "test/score"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredKinds() = %#v, want %#v", got, want)
|
||||
}
|
||||
notes := codecNotes{Items: []string{"second", "first"}}
|
||||
first, err := registry.Encode("test/notes", notes)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(notes) error = %v, want nil", err)
|
||||
}
|
||||
second, err := registry.Encode(" test/notes ", notes)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(notes again) error = %v, want nil", err)
|
||||
}
|
||||
if !bytes.Equal(first.Content, second.Content) {
|
||||
t.Fatalf("equal values encoded as %q and %q, want deterministic bytes", first.Content, second.Content)
|
||||
}
|
||||
decodedNotes, err := registry.Decode(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(notes) error = %v, want nil", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decodedNotes, notes) {
|
||||
t.Fatalf("Decode(notes) = %#v, want %#v", decodedNotes, notes)
|
||||
}
|
||||
|
||||
score := codecScore{Value: 17}
|
||||
serializedScore, err := registry.Encode("test/score", score)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(score) error = %v, want nil", err)
|
||||
}
|
||||
decodedScore, err := registry.Decode(serializedScore)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(score) error = %v, want nil", err)
|
||||
}
|
||||
if decodedScore != score {
|
||||
t.Fatalf("Decode(score) = %#v, want %#v", decodedScore, score)
|
||||
}
|
||||
|
||||
_, err = registry.Encode("test/notes", codecNotesAlias(notes))
|
||||
var typeErr *ArtifactCodecTypeError
|
||||
if !errors.As(err, &typeErr) {
|
||||
t.Fatalf("Encode(alias) error = %T %v, want ArtifactCodecTypeError", err, err)
|
||||
}
|
||||
if typeErr.ExpectedType == typeErr.ActualType {
|
||||
t.Fatalf("type error = %#v, want distinct exact types", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
codec := notesCodec()
|
||||
codec.kind = " test/notes "
|
||||
codec.schema.ID = " notes.v1 "
|
||||
codec.schema.Name = " notes "
|
||||
codec.schema.Version = " v1 "
|
||||
codec.mediaType = " application/json "
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
codec.schema.JSONSchema[0] = '['
|
||||
|
||||
spec, ok := registry.Spec("test/notes")
|
||||
if !ok {
|
||||
t.Fatal("Spec() ok = false, want true")
|
||||
}
|
||||
if spec.Kind != "test/notes" || spec.Schema.ID != "notes.v1" || spec.Schema.Name != "notes" || spec.Schema.Version != "v1" || spec.MediaType != "application/json" {
|
||||
t.Fatalf("Spec() = %#v, want normalized metadata", spec)
|
||||
}
|
||||
if spec.SchemaDigest != contracts.DigestArtifactSchema(spec.Schema) {
|
||||
t.Fatalf("schema digest = %q, want %q", spec.SchemaDigest, contracts.DigestArtifactSchema(spec.Schema))
|
||||
}
|
||||
|
||||
spec.Schema.JSONSchema[0] = '['
|
||||
again, _ := registry.Spec("test/notes")
|
||||
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}` {
|
||||
t.Fatalf("stored JSON Schema changed through Spec result: %q", again.Schema.JSONSchema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryRejectsInvalidRegistration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*testArtifactCodec[codecNotes])
|
||||
want string
|
||||
}{
|
||||
{name: "kind", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.kind = " " }, want: "kind"},
|
||||
{name: "schema id", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.ID = "" }, want: "schema id"},
|
||||
{name: "schema name", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.Name = "" }, want: "schema name"},
|
||||
{name: "schema version", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.Version = "" }, want: "schema version"},
|
||||
{name: "media type", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.mediaType = "" }, want: "media type"},
|
||||
{name: "empty JSON Schema", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.JSONSchema = nil }, want: "JSON Schema"},
|
||||
{name: "invalid JSON Schema", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.JSONSchema = []byte(`{"type":`) }, want: "valid JSON"},
|
||||
{name: "non-schema JSON", mutate: func(codec *testArtifactCodec[codecNotes]) { codec.schema.JSONSchema = []byte(`[]`) }, want: "JSON Schema is invalid"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
codec := notesCodec()
|
||||
test.mutate(&codec)
|
||||
if err := RegisterArtifactCodec(registry, codec); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryRejectsDuplicateKind(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, notesCodec()); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
duplicate := notesCodec()
|
||||
duplicate.kind = " test/notes "
|
||||
if err := RegisterArtifactCodec(registry, duplicate); err == nil || !strings.Contains(err.Error(), "already registered") {
|
||||
t.Fatalf("duplicate registration error = %v, want duplicate kind error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryRejectsNilRegistryAndCodec(t *testing.T) {
|
||||
codec := notesCodec()
|
||||
if err := RegisterArtifactCodec[codecNotes](nil, codec); err == nil || !strings.Contains(err.Error(), "registry") {
|
||||
t.Fatalf("nil registry error = %v, want registry error", err)
|
||||
}
|
||||
var nilCodec *testArtifactCodec[codecNotes]
|
||||
if err := RegisterArtifactCodec(NewArtifactCodecRegistry(), nilCodec); err == nil || !strings.Contains(err.Error(), "must not be nil") {
|
||||
t.Fatalf("nil codec error = %v, want codec error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryStrictDecodeAndTypedFailures(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, notesCodec()); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
valid, err := registry.Encode("test/notes", codecNotes{Items: []string{"one"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
for _, content := range [][]byte{
|
||||
[]byte(`{"items":["one"],"unknown":true}`),
|
||||
[]byte(`{"items":["one"]} {}`),
|
||||
} {
|
||||
candidate := contracts.CloneSerializedArtifact(valid)
|
||||
candidate.Content = content
|
||||
_, err := registry.Decode(candidate)
|
||||
var operationErr *ArtifactCodecOperationError
|
||||
if !errors.As(err, &operationErr) || operationErr.Operation != "decode" {
|
||||
t.Fatalf("Decode(%q) error = %T %v, want typed decode error", content, err, err)
|
||||
}
|
||||
}
|
||||
|
||||
wrongSchema := contracts.CloneSerializedArtifact(valid)
|
||||
wrongSchema.Schema.Version = "v2"
|
||||
_, err = registry.Decode(wrongSchema)
|
||||
var compatibilityErr *ArtifactCodecCompatibilityError
|
||||
if !errors.As(err, &compatibilityErr) {
|
||||
t.Fatalf("Decode(wrong schema) error = %T %v, want ArtifactCodecCompatibilityError", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) {
|
||||
codec := notesCodec()
|
||||
cause := errors.New("cannot encode notes")
|
||||
codec.encodeFunc = func(codecNotes) ([]byte, error) { return nil, cause }
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
_, err := registry.Encode("test/notes", codecNotes{})
|
||||
var operationErr *ArtifactCodecOperationError
|
||||
if !errors.As(err, &operationErr) || operationErr.Operation != "encode" || !errors.Is(err, cause) {
|
||||
t.Fatalf("Encode() error = %T %v, want typed wrapping encode error", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
shared := []byte(`{"items":["one"]}`)
|
||||
codec := notesCodec()
|
||||
codec.encodeFunc = func(codecNotes) ([]byte, error) { return shared, nil }
|
||||
codec.decodeFunc = func(content []byte) (codecNotes, error) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"one"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
artifact, err := registry.Encode("test/notes", codecNotes{})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
}
|
||||
shared[0] = '['
|
||||
if string(artifact.Content) != `{"items":["one"]}` {
|
||||
t.Fatalf("encoded content = %q after codec buffer mutation, want owned bytes", artifact.Content)
|
||||
}
|
||||
before := append([]byte(nil), artifact.Content...)
|
||||
if _, err := registry.Decode(artifact); err != nil {
|
||||
t.Fatalf("Decode() error = %v, want nil", err)
|
||||
}
|
||||
if !bytes.Equal(artifact.Content, before) {
|
||||
t.Fatalf("serialized content changed during decode: %q", artifact.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func notesCodec() testArtifactCodec[codecNotes] {
|
||||
return testArtifactCodec[codecNotes]{
|
||||
kind: "test/notes",
|
||||
schema: contracts.ArtifactSchema{
|
||||
ID: "notes.v1",
|
||||
Name: "notes",
|
||||
Version: "v1",
|
||||
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}`),
|
||||
},
|
||||
mediaType: "application/json",
|
||||
encodeFunc: func(value codecNotes) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
},
|
||||
decodeFunc: func(content []byte) (codecNotes, error) {
|
||||
var value codecNotes
|
||||
return value, decodeStrictJSON(content, &value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func scoreCodec() testArtifactCodec[codecScore] {
|
||||
return testArtifactCodec[codecScore]{
|
||||
kind: "test/score",
|
||||
schema: contracts.ArtifactSchema{
|
||||
ID: "score.v1",
|
||||
Name: "score",
|
||||
Version: "v1",
|
||||
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"value":{"type":"integer"}},"required":["value"],"type":"object"}`),
|
||||
},
|
||||
mediaType: "application/json",
|
||||
encodeFunc: func(value codecScore) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
},
|
||||
decodeFunc: func(content []byte) (codecScore, error) {
|
||||
var value codecScore
|
||||
return value, decodeStrictJSON(content, &value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStrictJSON(content []byte, out any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("unexpected trailing JSON value")
|
||||
}
|
||||
return fmt.Errorf("decode trailing JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user