Files
moteva/server/internal/application/asset_cleanup_test.go
T
root 9a07f863ea fix(design): preserve existing canvas nodes when compressed snapshot omits them
SaveCanvas treated a nil Nodes/Connections slice from a compressed snapshot
as an empty canvas, wiping existing content. Distinguish nil (field omitted,
keep the project's current nodes/connections) from an explicit empty slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:10:07 +08:00

501 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package application
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/infrastructure/memory"
)
type fakeAssetStorage struct {
deleted chan string
uploaded chan design.AssetObjectRequest
}
type fakeJobQueue struct {
jobs []design.Job
}
func (q *fakeJobQueue) Enqueue(ctx context.Context, job design.Job) error {
q.jobs = append(q.jobs, job)
return nil
}
func newFakeAssetStorage() *fakeAssetStorage {
return &fakeAssetStorage{
deleted: make(chan string, 8),
uploaded: make(chan design.AssetObjectRequest, 8),
}
}
func (s *fakeAssetStorage) CreateUpload(ctx context.Context, req design.AssetUploadRequest) (design.AssetUpload, error) {
return design.AssetUpload{}, nil
}
func (s *fakeAssetStorage) PutObject(ctx context.Context, req design.AssetObjectRequest) (design.AssetObject, error) {
s.uploaded <- req
return design.AssetObject{PublicURL: "http://localhost:19000/canvas-assets/" + req.FileName}, nil
}
func (s *fakeAssetStorage) DeleteObject(ctx context.Context, publicURL string) error {
s.deleted <- publicURL
return nil
}
func TestDeleteProjectEnqueuesAssetCleanupJobWhenJobQueueIsConfigured(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
queue := &fakeJobQueue{}
service := NewDesignService(repo, nil, assets, nil, nil)
service.SetJobQueue(queue)
project := design.Project{
ID: "project-delete-job",
Title: "Delete project",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{ID: "image-1", Type: design.NodeTypeImage, Content: "http://localhost:19000/canvas-assets/a.png", Status: "success"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if err := service.DeleteProject(ctx, project.ID); err != nil {
t.Fatal(err)
}
if len(queue.jobs) != 1 {
t.Fatalf("expected one cleanup job, got %#v", queue.jobs)
}
job := queue.jobs[0]
if job.Kind != design.JobAssetCleanup || len(job.PublicURLs) != 1 || job.PublicURLs[0] != "http://localhost:19000/canvas-assets/a.png" {
t.Fatalf("unexpected cleanup job: %#v", job)
}
select {
case url := <-assets.deleted:
t.Fatalf("expected no in-process delete when job queue succeeds, got %s", url)
default:
}
}
func TestSaveCanvasQueuesDeletedImageAsset(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
project := design.Project{
ID: "project-delete-node",
Title: "Delete node",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{ID: "image-1", Type: design.NodeTypeImage, Title: "Image", Content: "http://localhost:19000/canvas-assets/a.png", Status: "success"},
{ID: "image-2", Type: design.NodeTypeImage, Title: "Keep", Content: "http://localhost:19000/canvas-assets/b.png", Status: "success"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if _, err := service.SaveCanvas(ctx, project.ID, design.Viewport{K: 1}, []design.Node{project.Nodes[1]}, nil, "", "", ""); err != nil {
t.Fatal(err)
}
select {
case url := <-assets.deleted:
if url != "http://localhost:19000/canvas-assets/a.png" {
t.Fatalf("unexpected deleted url: %s", url)
}
case <-time.After(time.Second):
t.Fatal("expected async asset deletion")
}
}
func TestSaveCanvasAcceptsCompressedSnapshotPayload(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
service.now = func() time.Time {
return time.Unix(101, 0).UTC()
}
project := design.Project{
ID: "project-compressed-save",
Title: "Compressed save",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Unix(100, 0).UTC(),
Version: "100000",
SnapshotID: "snapshot-old",
Viewport: design.Viewport{X: 1, Y: 2, K: 1},
Nodes: []design.Node{
{ID: "old-image", Type: design.NodeTypeImage, Title: "Old", Content: "http://localhost:19000/canvas-assets/old.png", Status: "success"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
snapshotProject := project
snapshotProject.Version = project.Version
snapshotProject.SnapshotID = "snapshot-client"
snapshotProject.Viewport = design.Viewport{X: 12, Y: 34, K: 0.8}
snapshotProject.Nodes = []design.Node{
{ID: "new-image", Type: design.NodeTypeImage, Title: "New", Content: "http://localhost:19000/canvas-assets/new.png", Status: "success"},
}
snapshotProject.Connections = []design.Connection{
{ID: "connection-1", FromNodeID: "old-image", ToNodeID: "new-image"},
}
canvas, err := design.EncodeCanvasSnapshot(snapshotProject)
if err != nil {
t.Fatal(err)
}
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{}, nil, nil, "", "", canvas)
if err != nil {
t.Fatal(err)
}
if saved.Version != "101000" {
t.Fatalf("expected refreshed version, got %s", saved.Version)
}
if saved.SnapshotID != "snapshot-client" {
t.Fatalf("expected client snapshot id, got %s", saved.SnapshotID)
}
if saved.Viewport != snapshotProject.Viewport {
t.Fatalf("unexpected viewport: %#v", saved.Viewport)
}
if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "new-image" {
t.Fatalf("unexpected saved nodes: %#v", saved.Nodes)
}
if len(saved.Connections) != 1 || saved.Connections[0].ID != "connection-1" {
t.Fatalf("unexpected saved connections: %#v", saved.Connections)
}
}
func TestSaveCanvasPreservesNodesWhenCompressedSnapshotOmitsThem(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
service.now = func() time.Time {
return time.Unix(101, 0).UTC()
}
project := design.Project{
ID: "project-null-snapshot-nodes",
Title: "Null snapshot nodes",
Status: design.StatusReady,
UpdatedAt: time.Unix(100, 0).UTC(),
Version: "100000",
SnapshotID: "snapshot-old",
Viewport: design.Viewport{X: 1, Y: 2, K: 1},
Nodes: []design.Node{
{ID: "existing-image", Type: design.NodeTypeImage, Title: "Existing", Content: "http://localhost:19000/canvas-assets/existing.png", Status: "success"},
},
Connections: []design.Connection{
{ID: "existing-connection", FromNodeID: "existing-image", ToNodeID: "existing-image"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
snapshotProject := project
snapshotProject.SnapshotID = "snapshot-client"
snapshotProject.Viewport = design.Viewport{X: 12, Y: 34, K: 0.8}
snapshotProject.Nodes = nil
snapshotProject.Connections = nil
canvas, err := design.EncodeCanvasSnapshot(snapshotProject)
if err != nil {
t.Fatal(err)
}
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{}, nil, nil, "", "", canvas)
if err != nil {
t.Fatal(err)
}
if saved.SnapshotID != "snapshot-client" {
t.Fatalf("expected client snapshot id, got %s", saved.SnapshotID)
}
if saved.Viewport != snapshotProject.Viewport {
t.Fatalf("unexpected viewport: %#v", saved.Viewport)
}
if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "existing-image" {
t.Fatalf("expected existing nodes to be preserved, got %#v", saved.Nodes)
}
if len(saved.Connections) != 1 || saved.Connections[0].ID != "existing-connection" {
t.Fatalf("expected existing connections to be preserved, got %#v", saved.Connections)
}
}
func TestSaveCanvasDoesNotDeleteImageAssetReferencedByAgentMessage(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
publicURL := "http://localhost:19000/canvas-assets/a.png"
project := design.Project{
ID: "project-delete-node-with-message-ref",
Title: "Delete node with message reference",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{ID: "image-1", Type: design.NodeTypeImage, Title: "Image", Content: publicURL, Status: "success"},
},
Messages: []design.Message{
{
ID: "user-message",
Role: "user",
Type: "user",
Content: "请参考 参考图 1image):http://localhost:19000/canvas-assets/a.png?x-oss-process=image/resize,w_100,m_lfit/format,webp",
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if _, err := service.SaveCanvas(ctx, project.ID, design.Viewport{K: 1}, nil, nil, "", "", ""); err != nil {
t.Fatal(err)
}
select {
case url := <-assets.deleted:
t.Fatalf("expected referenced asset to stay in OSS, got deletion for %s", url)
default:
}
}
func TestDeleteAssetQueuesSingleUploadedAsset(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
publicURL := "http://localhost:19000/canvas-assets/reference.png"
if err := service.DeleteAsset(ctx, publicURL); err != nil {
t.Fatal(err)
}
select {
case url := <-assets.deleted:
if url != publicURL {
t.Fatalf("unexpected deleted url: %s", url)
}
case <-time.After(time.Second):
t.Fatal("expected async asset deletion")
}
}
func TestDeleteAssetSkipsAssetReferencedByProject(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
publicURL := "http://localhost:19000/canvas-assets/reference.png"
project := design.Project{
ID: "project-delete-asset-ref",
Title: "Delete asset ref",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Messages: []design.Message{
{ID: "message-1", Role: "user", Type: "user", Content: "Reference image 1 (image): http://localhost:19000/canvas-assets/reference.png?x-oss-process=image/resize,w_100,m_lfit/format,webp"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if err := service.DeleteAsset(ctx, publicURL); err != nil {
t.Fatal(err)
}
select {
case url := <-assets.deleted:
t.Fatalf("expected referenced asset cleanup to be skipped, got %s", url)
default:
}
}
func TestSaveCanvasPersistsRasterDataURLAsWebPWithOriginalExtension(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
project := design.Project{
ID: "project-raster",
Title: "Raster",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
var pngData bytes.Buffer
source := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
source.Set(x, y, color.RGBA{R: 32, G: 96, B: 180, A: 255})
}
}
if err := png.Encode(&pngData, source); err != nil {
t.Fatal(err)
}
node := design.Node{
ID: "image-1",
Type: design.NodeTypeImage,
Title: "Raster image",
Content: "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngData.Bytes()),
Status: "success",
}
if _, err := service.SaveCanvas(ctx, project.ID, design.Viewport{K: 1}, []design.Node{node}, nil, "", "", ""); err != nil {
t.Fatal(err)
}
select {
case upload := <-assets.uploaded:
if upload.ContentType != "image/webp" {
t.Fatalf("expected WebP content type, got %s", upload.ContentType)
}
if !strings.HasSuffix(upload.FileName, ".png") {
t.Fatalf("expected original extension to be preserved, got %s", upload.FileName)
}
if len(upload.Data) < 12 || !bytes.HasPrefix(upload.Data, []byte("RIFF")) || string(upload.Data[8:12]) != "WEBP" {
t.Fatalf("expected uploaded WebP data, got first bytes %#v", upload.Data[:min(len(upload.Data), 12)])
}
case <-time.After(time.Second):
t.Fatal("expected image upload")
}
}
func TestSaveCanvasPersistsPenStrokeSVGDataURL(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
project := design.Project{
ID: "project-pen-stroke",
Title: "Pen stroke",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
node := design.Node{
ID: "stroke-1",
Type: design.NodeTypeImage,
Title: "Pen stroke",
Content: `data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M%200%200%20L%2010%200%22%2F%3E%3C%2Fsvg%3E`,
Status: "success",
LayerRole: "pen-stroke",
}
saved, err := service.SaveCanvas(ctx, project.ID, design.Viewport{K: 1}, []design.Node{node}, nil, "", "", "")
if err != nil {
t.Fatal(err)
}
if len(saved.Nodes) != 1 || saved.Nodes[0].Content == node.Content {
t.Fatalf("expected pen stroke to be persisted as asset url, got %#v", saved.Nodes)
}
select {
case upload := <-assets.uploaded:
if upload.ContentType != "image/svg+xml" {
t.Fatalf("unexpected content type: %s", upload.ContentType)
}
if string(upload.Data) == "" {
t.Fatal("expected uploaded SVG data")
}
case <-time.After(time.Second):
t.Fatal("expected SVG upload")
}
}
func TestDeleteProjectRemovesProjectAndQueuesAllAssets(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
assets := newFakeAssetStorage()
service := NewDesignService(repo, nil, assets, nil, nil)
project := design.Project{
ID: "project-delete",
Title: "Delete project",
Brief: "brief",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{ID: "image-1", Type: design.NodeTypeImage, Title: "Image", Content: "http://localhost:19000/canvas-assets/a.png", Status: "success"},
},
Messages: []design.Message{
{
ID: "artifact-message",
Role: "tool",
Type: "tool_use",
Title: "generate_media",
Content: `[{"event_data":{"artifact":[{"image_url":"http://localhost:19000/canvas-assets/c.png"}]}}]`,
},
{
ID: "reference-message",
Role: "user",
Type: "user",
Content: `参考 [@image:#1:ref:http://localhost:19000/canvas-assets/d.png?x-oss-process=image/resize,w_100,m_lfit/format,webp] 和 ![brief](http://localhost:19000/canvas-assets/e.webp)`,
},
},
}
snapshotProject := project
snapshotProject.Nodes = append(snapshotProject.Nodes, design.Node{
ID: "snapshot-image",
Type: design.NodeTypeImage,
Title: "Snapshot image",
Content: "http://localhost:19000/canvas-assets/b.png",
Status: "success",
})
canvas, err := design.EncodeCanvasSnapshot(snapshotProject)
if err != nil {
t.Fatal(err)
}
project.Canvas = canvas
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if err := service.DeleteProject(ctx, project.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.Get(ctx, project.ID); err != design.ErrNotFound {
t.Fatalf("expected project to be deleted, got %v", err)
}
deleted := map[string]bool{}
for len(deleted) < 5 {
select {
case url := <-assets.deleted:
deleted[url] = true
case <-time.After(time.Second):
t.Fatalf("expected all assets to be queued, got %#v", deleted)
}
}
if !deleted["http://localhost:19000/canvas-assets/a.png"] ||
!deleted["http://localhost:19000/canvas-assets/b.png"] ||
!deleted["http://localhost:19000/canvas-assets/c.png"] ||
!deleted["http://localhost:19000/canvas-assets/d.png?x-oss-process=image/resize,w_100,m_lfit/format,webp"] ||
!deleted["http://localhost:19000/canvas-assets/e.webp"] {
t.Fatalf("unexpected deleted urls: %#v", deleted)
}
}