Files
moteva/server/internal/application/asset_cleanup_test.go
T
root 263748af4a feat(api): add Lovart-style project and agent thread endpoints
Add canva/canda-compatible routes for lightweight project open/save and
agent thread history:
- POST /canva/project/queryProject, saveProject
- POST /canva/agent/queryAgentLastThread, agentThreadList
- GET /canda/chat-history, /canda/thread/status

Canvas save now accepts a compressed SHAKKERDATA:// snapshot payload,
preserves connections, and returns a SaveProject response; blank
successful image nodes are backfilled from generated artifacts. Frontend
designGateway adopts the new save/query flow.

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

447 lines
14 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 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)
}
}