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 TestImportMockStoresAccessibleGeneratedAssetWithoutRemoteFetch(t *testing.T) { cat := &memoryCatalog{} blobs := &memoryBlobs{} remote := &memoryRemote{} 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.ImportMock(context.Background(), PlatformScope("owner-a"), ImportMockCommand{Capability: "image.generate", JobID: "job-1", Kind: KindImage, Tags: []string{"generated", "job:job-1"}}) if err != nil { t.Fatal(err) } if created.ID != "asset-1" || created.StoragePath != "generated-results/2026-08-13/file-1-mock-image.svg" || created.URL != "https://app.test/generated-results/2026-08-13/file-1-mock-image.svg" || created.Metadata["mock"] != true || remote.called { t.Fatalf("created = %#v remoteCalled=%v", created, remote.called) } if blobs.putContentType != "image/svg+xml" || !bytes.Contains(blobs.putBody, []byte("job-1")) { t.Fatalf("stored contentType=%q body=%q", blobs.putContentType, blobs.putBody) } } 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 }