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)
			}
		})
	}
}