10-backend/internal/artifacts/application/service_test.go
package application
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
artifactdomain "engineering-platform/internal/artifacts/domain"
artifactports "engineering-platform/internal/artifacts/ports"
identityports "engineering-platform/internal/identity/ports"
"engineering-platform/internal/sharedkernel"
)
type fakeRepo struct {
artifacts map[sharedkernel.ID]artifactdomain.Artifact
revisions map[sharedkernel.ID]artifactdomain.Revision
createCalls int
appendCalls int
lastListQuery artifactports.ListQuery
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{artifacts: map[sharedkernel.ID]artifactdomain.Artifact{}, revisions: map[sharedkernel.ID]artifactdomain.Revision{}}
}
func (r *fakeRepo) Create(_ context.Context, a artifactdomain.Artifact, rev artifactdomain.Revision, _ sharedkernel.ChangeContext) error {
r.createCalls++
a.CurrentRevisionID = &rev.ID
r.artifacts[a.ID] = a
r.revisions[rev.ID] = rev
return nil
}
func (r *fakeRepo) AppendRevision(_ context.Context, rev artifactdomain.Revision, expected *sharedkernel.ID, _ sharedkernel.ChangeContext) error {
r.appendCalls++
a, ok := r.artifacts[rev.ArtifactID]
if !ok {
return sharedkernel.ErrNotFound
}
if expected != nil && (a.CurrentRevisionID == nil || *a.CurrentRevisionID != *expected) {
return sharedkernel.ErrConflict
}
a.CurrentRevisionID = &rev.ID
r.artifacts[a.ID] = a
r.revisions[rev.ID] = rev
return nil
}
func (r *fakeRepo) FindByID(_ context.Context, id sharedkernel.ID) (artifactdomain.Artifact, error) {
a, ok := r.artifacts[id]
if !ok {
return artifactdomain.Artifact{}, sharedkernel.ErrNotFound
}
return a, nil
}
func (r *fakeRepo) FindRevisionByID(_ context.Context, id sharedkernel.ID) (artifactdomain.Revision, error) {
v, ok := r.revisions[id]
if !ok {
return artifactdomain.Revision{}, sharedkernel.ErrNotFound
}
return v, nil
}
func (r *fakeRepo) List(_ context.Context, query artifactports.ListQuery) ([]artifactdomain.Artifact, error) {
r.lastListQuery = query
out := make([]artifactdomain.Artifact, 0, len(r.artifacts))
for _, a := range r.artifacts {
out = append(out, a)
}
return out, nil
}
type allowAll struct{}
func (allowAll) Authorize(context.Context, identityports.Subject, identityports.Action, identityports.Resource) error {
return nil
}
type denyAll struct{ err error }
func (d denyAll) Authorize(context.Context, identityports.Subject, identityports.Action, identityports.Resource) error {
return d.err
}
type ids struct{ values []sharedkernel.ID }
func (i *ids) NewID() (sharedkernel.ID, error) {
if len(i.values) == 0 {
return "", errors.New("no test id available")
}
v := i.values[0]
i.values = i.values[1:]
return v, nil
}
type clock struct{ t time.Time }
func (c clock) Now() time.Time { return c.t }
func newServiceForTest(t *testing.T, repo *fakeRepo, authorizer identityports.Authorizer, values ...sharedkernel.ID) *Service {
t.Helper()
svc, err := NewService(repo, authorizer, &ids{values: values}, clock{time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)})
if err != nil {
t.Fatalf("new service: %v", err)
}
return svc
}
// TestArtifactLifecycle schützt Revisionierung, Optimistic Concurrency, Autorisierung und Unicode-Erhalt im Application Layer.
func TestArtifactLifecycle(t *testing.T) {
subject := identityports.Subject{UserID: "u1"}
t.Run("erzeugt ein Artefakt und speichert danach eine unveränderliche zweite Revision", func(t *testing.T) {
repo := newFakeRepo()
svc := newServiceForTest(t, repo, allowAll{}, "a1", "r1", "r2")
created, err := svc.Create(context.Background(), subject, CreateArtifactCommand{Type: "document", StructuredContent: json.RawMessage(`{"title":"Änderung – Україна"}`)})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Revision.Sequence != 1 {
t.Fatalf("sequence=%d, want 1", created.Revision.Sequence)
}
first := created.Revision.ID
updated, err := svc.AppendRevision(context.Background(), subject, AppendRevisionCommand{ArtifactID: "a1", ExpectedCurrentRevision: &first, StructuredContent: json.RawMessage(`{"title":"Second"}`)})
if err != nil {
t.Fatalf("append: %v", err)
}
if updated.Revision.Sequence != 2 {
t.Fatalf("sequence=%d, want 2", updated.Revision.Sequence)
}
if updated.Revision.PreviousRevisionID == nil || *updated.Revision.PreviousRevisionID != first {
t.Fatal("second revision does not reference first revision")
}
if got := string(repo.revisions["r1"].StructuredContent); got != `{"title":"Änderung – Україна"}` {
t.Fatalf("first revision changed: %s", got)
}
})
t.Run("verweigert eine konkurrierende Änderung mit veralteter Ausgangsrevision", func(t *testing.T) {
repo := newFakeRepo()
svc := newServiceForTest(t, repo, allowAll{}, "a1", "r1", "r2")
created, err := svc.Create(context.Background(), subject, CreateArtifactCommand{Type: "document", StructuredContent: json.RawMessage(`{"title":"A"}`)})
if err != nil {
t.Fatalf("create: %v", err)
}
stale := sharedkernel.MustID("older-revision")
_, err = svc.AppendRevision(context.Background(), subject, AppendRevisionCommand{ArtifactID: created.Artifact.ID, ExpectedCurrentRevision: &stale, StructuredContent: json.RawMessage(`{"title":"B"}`)})
if !errors.Is(err, sharedkernel.ErrConflict) {
t.Fatalf("error=%v, want conflict", err)
}
if len(repo.revisions) != 1 {
t.Fatalf("revision count=%d, want 1", len(repo.revisions))
}
})
t.Run("verweigert ungültiges JSON bevor das Repository verändert wird", func(t *testing.T) {
repo := newFakeRepo()
svc := newServiceForTest(t, repo, allowAll{}, "a1", "r1")
_, err := svc.Create(context.Background(), subject, CreateArtifactCommand{Type: "document", StructuredContent: json.RawMessage(`{"broken"`)})
if !errors.Is(err, sharedkernel.ErrInvalidInput) {
t.Fatalf("error=%v, want invalid input", err)
}
if repo.createCalls != 0 {
t.Fatalf("create calls=%d, want 0", repo.createCalls)
}
})
t.Run("verweigert fehlende Berechtigung bevor IDs erzeugt oder Daten gespeichert werden", func(t *testing.T) {
repo := newFakeRepo()
svc := newServiceForTest(t, repo, denyAll{err: sharedkernel.ErrForbidden}, "a1", "r1")
_, err := svc.Create(context.Background(), subject, CreateArtifactCommand{Type: "document", StructuredContent: json.RawMessage(`{"title":"A"}`)})
if !errors.Is(err, sharedkernel.ErrForbidden) {
t.Fatalf("error=%v, want forbidden", err)
}
if repo.createCalls != 0 {
t.Fatalf("create calls=%d, want 0", repo.createCalls)
}
})
}
// TestListArtifactsBoundaries schützt die API vor unbegrenzten Listenabfragen.
func TestListArtifactsBoundaries(t *testing.T) {
for _, tc := range []struct {
name string
requested, want uint32
}{
{"normalisiert ein fehlendes Limit auf 50", 0, 50},
{"bewahrt ein zulässiges Limit", 200, 200},
{"normalisiert ein zu großes Limit auf 50", 201, 50},
} {
t.Run(tc.name, func(t *testing.T) {
repo := newFakeRepo()
svc := newServiceForTest(t, repo, allowAll{})
_, err := svc.List(context.Background(), identityports.Subject{UserID: "u1"}, ListArtifactsQuery{Limit: tc.requested})
if err != nil {
t.Fatalf("list: %v", err)
}
if repo.lastListQuery.Limit != tc.want {
t.Fatalf("limit=%d, want %d", repo.lastListQuery.Limit, tc.want)
}
})
}
}