44406b72db
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>
387 lines
12 KiB
Go
387 lines
12 KiB
Go
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: "请参考 参考图 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] 和 `,
|
||
},
|
||
},
|
||
}
|
||
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)
|
||
}
|
||
}
|