Files
NianAIGC/backend/internal/assets/service_test.go
2026-08-17 23:44:14 +08:00

229 lines
8.9 KiB
Go

package assets
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"testing"
"time"
)
func TestPlatformScopeOwnerIsolationAndDefaults(t *testing.T) {
cat := &memoryCatalog{}
svc := NewService(cat, nil, nil, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }, func(string) string { return "asset-1" })
scope := PlatformScope("owner-a")
created, err := svc.CreateExternal(context.Background(), scope, CreateExternalCommand{URL: "https://example.test/a.png"})
if err != nil {
t.Fatal(err)
}
if created.OwnerID != "owner-a" || created.Name != "外部图片" || created.Kind != KindImage || created.Source != SourceExternal || !reflect.DeepEqual(created.Tags, []string{"external"}) || created.Metadata["registeredFrom"] != "api" {
t.Fatalf("created = %#v", created)
}
if _, err := svc.Get(context.Background(), PlatformScope("owner-b"), created.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("cross-owner Get error = %v", err)
}
if _, err := svc.Delete(context.Background(), PlatformScope("owner-b"), created.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("cross-owner Delete error = %v", err)
}
}
func TestPlatformExplicitEmptyTagsArePreserved(t *testing.T) {
cat := &memoryCatalog{}
svc := NewService(cat, nil, nil, time.Now, func(string) string { return "asset-empty-tags" })
created, err := svc.CreateExternal(context.Background(), PlatformScope("owner-a"), CreateExternalCommand{URL: "https://example.test/a.png", Tags: []string{}})
if err != nil {
t.Fatal(err)
}
if created.Tags == nil || len(created.Tags) != 0 {
t.Fatalf("tags = %#v, want explicit empty array", created.Tags)
}
}
func TestPublicScopeAddsClientIdentityAndUsesCatalogVisibility(t *testing.T) {
cat := &memoryCatalog{}
svc := NewService(cat, nil, nil, time.Now, func(string) string { return "asset-public" })
created, err := svc.CreateExternal(context.Background(), PublicScope("agent-a"), CreateExternalCommand{URL: "https://example.test/a.png", Tags: []string{"campaign"}})
if err != nil {
t.Fatal(err)
}
wantTags := []string{"campaign", "external", "public-api", "api-client:agent-a"}
if created.OwnerID != "api:agent-a" || created.Name != "外部素材" || !reflect.DeepEqual(created.Tags, wantTags) || created.Metadata["externalClientId"] != "agent-a" || cat.lastPublicLimit != 0 {
t.Fatalf("created = %#v", created)
}
cat.publicVisible = map[string]bool{created.ID: true}
if _, err := svc.Get(context.Background(), PublicScope("agent-a"), created.ID); err != nil {
t.Fatal(err)
}
if cat.lastPublicClient != "agent-a" || cat.lastPublicLimit != PublicJobLookupLimit {
t.Fatalf("public lookup = %q, %d", cat.lastPublicClient, cat.lastPublicLimit)
}
}
func TestUploadCompensatesBlobWhenCatalogCreateFails(t *testing.T) {
cat := &memoryCatalog{createErr: errors.New("database down")}
blobs := &memoryBlobs{}
svc := NewService(cat, blobs, nil, time.Now, func(prefix string) string { return prefix + "-1" })
_, err := svc.Upload(context.Background(), PlatformScope("owner-a"), UploadCommand{Bytes: []byte("png"), FileName: "poster.png", ContentType: "image/png", Origin: "https://app.test"})
if !errors.Is(err, cat.createErr) {
t.Fatalf("Upload error = %v", err)
}
if len(blobs.deleted) != 1 || blobs.deleted[0] != blobs.putKey {
t.Fatalf("compensating deletes = %#v, put key %q", blobs.deleted, blobs.putKey)
}
}
func TestImportGeneratedDownloadsStoresAndPersistsGeneratedMetadata(t *testing.T) {
cat := &memoryCatalog{}
blobs := &memoryBlobs{}
remote := &memoryRemote{blob: Blob{Body: io.NopCloser(bytes.NewReader([]byte("png"))), ContentType: "image/png", Size: 3}}
ids := []string{"file-1", "asset-1"}
svc := NewService(cat, blobs, remote, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }, func(string) string {
id := ids[0]
ids = ids[1:]
return id
})
created, err := svc.ImportGenerated(context.Background(), PlatformScope("owner-a"), ImportGeneratedCommand{URL: "https://provider.test/output.png?token=x", Capability: "image.generate", JobID: "job-1", Kind: KindImage, Tags: []string{"generated", "job:job-1"}})
if err != nil {
t.Fatal(err)
}
if created.Source != SourceGenerated || created.StoragePath != "generated-results/2026-08-13/file-1-output.png" || created.Metadata["importedFrom"] == nil || created.Metadata["jobId"] != "job-1" || !remote.called {
t.Fatalf("created = %#v", created)
}
}
func TestImportGeneratedCompensatesBlobWhenCatalogFails(t *testing.T) {
cat := &memoryCatalog{createErr: errors.New("database unavailable")}
blobs := &memoryBlobs{}
remote := &memoryRemote{blob: Blob{Body: io.NopCloser(bytes.NewReader([]byte("video"))), ContentType: "video/mp4", Size: 5}}
svc := NewService(cat, blobs, remote, time.Now, func(prefix string) string { return prefix + "-1" })
_, err := svc.ImportGenerated(context.Background(), PlatformScope("owner-a"), ImportGeneratedCommand{URL: "https://provider.test/output.mp4", Capability: "video.generate", JobID: "job-1"})
if !errors.Is(err, cat.createErr) || len(blobs.deleted) != 1 || blobs.deleted[0] != blobs.putKey {
t.Fatalf("error=%v deleted=%#v key=%q", err, blobs.deleted, blobs.putKey)
}
}
func TestDeleteLeavesCatalogWhenBlobDeletionFails(t *testing.T) {
cat := &memoryCatalog{assets: []Asset{{ID: "a", OwnerID: "o", StoragePath: "uploads/a"}}}
blobs := &memoryBlobs{deleteErr: errors.New("storage down")}
svc := NewService(cat, blobs, nil, time.Now, nil)
if _, err := svc.Delete(context.Background(), PlatformScope("o"), "a"); !errors.Is(err, blobs.deleteErr) {
t.Fatalf("Delete error = %v", err)
}
if _, found, _ := cat.GetOwner(context.Background(), "o", "a"); !found {
t.Fatal("catalog row deleted after blob failure")
}
}
func TestDownloadUsesBlobBeforeRemoteFetcher(t *testing.T) {
cat := &memoryCatalog{assets: []Asset{{ID: "a", OwnerID: "o", Name: "a.png", URL: "https://remote.test/a.png", StoragePath: "uploads/a.png", Metadata: map[string]any{"contentType": "image/png"}}}}
blobs := &memoryBlobs{read: Blob{Body: io.NopCloser(bytes.NewReader([]byte("local"))), ContentType: "image/png", Size: 5}}
remote := &memoryRemote{blob: Blob{Body: io.NopCloser(bytes.NewReader([]byte("remote")))}}
svc := NewService(cat, blobs, remote, time.Now, nil)
got, err := svc.Download(context.Background(), PlatformScope("o"), "a")
if err != nil {
t.Fatal(err)
}
defer got.Body.Close()
b, _ := io.ReadAll(got.Body)
if string(b) != "local" || remote.called {
t.Fatalf("download = %q remoteCalled=%v", b, remote.called)
}
}
type memoryCatalog struct {
assets []Asset
createErr error
publicVisible map[string]bool
lastPublicClient string
lastPublicLimit int
}
func (m *memoryCatalog) ListOwner(_ context.Context, owner string) ([]Asset, error) {
var out []Asset
for _, a := range m.assets {
if a.OwnerID == owner {
out = append(out, a)
}
}
return out, nil
}
func (m *memoryCatalog) GetOwner(_ context.Context, owner, id string) (Asset, bool, error) {
for _, a := range m.assets {
if a.OwnerID == owner && a.ID == id {
return a, true, nil
}
}
return Asset{}, false, nil
}
func (m *memoryCatalog) ListPublic(ctx context.Context, owner, client string, limit int) ([]Asset, error) {
m.lastPublicClient = client
m.lastPublicLimit = limit
all, _ := m.ListOwner(ctx, owner)
var out []Asset
for _, a := range all {
if m.publicVisible[a.ID] || contains(a.Tags, ClientTag(client)) {
out = append(out, a)
}
}
return out, nil
}
func (m *memoryCatalog) GetPublic(_ context.Context, owner, client, id string, limit int) (Asset, bool, error) {
m.lastPublicClient = client
m.lastPublicLimit = limit
for _, a := range m.assets {
if a.OwnerID == owner && a.ID == id && (m.publicVisible[id] || contains(a.Tags, ClientTag(client))) {
return a, true, nil
}
}
return Asset{}, false, nil
}
func (m *memoryCatalog) Create(_ context.Context, a Asset) (Asset, error) {
if m.createErr != nil {
return Asset{}, m.createErr
}
m.assets = append(m.assets, a)
return a, nil
}
func (m *memoryCatalog) DeleteOwner(_ context.Context, owner, id string) (Asset, bool, error) {
for i, a := range m.assets {
if a.OwnerID == owner && a.ID == id {
m.assets = append(m.assets[:i], m.assets[i+1:]...)
return a, true, nil
}
}
return Asset{}, false, nil
}
type memoryBlobs struct {
putKey string
putBody []byte
putContentType string
deleted []string
read Blob
deleteErr error
}
func (m *memoryBlobs) Put(_ context.Context, key string, body io.Reader, _ int64, contentType string) (StoredObject, error) {
m.putKey = key
m.putBody, _ = io.ReadAll(body)
m.putContentType = contentType
return StoredObject{Key: key, URL: "https://app.test/" + key}, nil
}
func (m *memoryBlobs) Read(context.Context, string) (Blob, error) { return m.read, nil }
func (m *memoryBlobs) Delete(_ context.Context, key string) error {
m.deleted = append(m.deleted, key)
return m.deleteErr
}
type memoryRemote struct {
called bool
blob Blob
}
func (m *memoryRemote) Fetch(context.Context, string) (Blob, error) {
m.called = true
return m.blob, nil
}