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)
|
|||
|
|
}
|
|||
|
|
}
|