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: "请参考 参考图 1(image):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) } }