Files
moteva/server/internal/application/asset_cleanup_test.go
T
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

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

387 lines
12 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 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)
}
}