789f51b29e
Add a brand kit domain with memory and postgres stores, a BrandKitService for CRUD and resolution, and REST endpoints to list, upsert, and delete brand kits. Projects can bind a brand kit whose resolved context and reference images feed into the agent's long-term memory and design prompts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
6.0 KiB
Go
174 lines
6.0 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"img_infinite_canvas/internal/domain/brandkit"
|
|
"img_infinite_canvas/internal/domain/design"
|
|
"img_infinite_canvas/internal/infrastructure/memory"
|
|
)
|
|
|
|
const (
|
|
brandKitTestUserA = "11111111-1111-4111-8111-111111111111"
|
|
brandKitTestUserB = "22222222-2222-4222-8222-222222222222"
|
|
)
|
|
|
|
func TestBrandKitServiceEnforcesOwnershipAndSingleDefault(t *testing.T) {
|
|
service := NewBrandKitService(memory.NewBrandKitStore())
|
|
ctxA := design.ContextWithUserID(context.Background(), brandKitTestUserA)
|
|
ctxB := design.ContextWithUserID(context.Background(), brandKitTestUserB)
|
|
|
|
first, err := service.Upsert(ctxA, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !first.IsDefault {
|
|
t.Fatal("expected first kit to be default")
|
|
}
|
|
if _, err := service.Upsert(ctxA, "brand-kit-beta", testBrandKitDocument("brand-kit-beta", "Beta"), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kits, err := service.List(ctxA)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defaults := 0
|
|
for _, kit := range kits {
|
|
if kit.IsDefault {
|
|
defaults++
|
|
if kit.ID != "brand-kit-beta" {
|
|
t.Fatalf("unexpected default kit %q", kit.ID)
|
|
}
|
|
}
|
|
}
|
|
if defaults != 1 {
|
|
t.Fatalf("expected one default, got %d", defaults)
|
|
}
|
|
if _, err := service.Get(ctxB, first.ID); !errors.Is(err, brandkit.ErrNotFound) {
|
|
t.Fatalf("expected user isolation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBrandKitServiceCompilesAuthoritativeContext(t *testing.T) {
|
|
service := NewBrandKitService(memory.NewBrandKitStore())
|
|
ctx := design.ContextWithUserID(context.Background(), brandKitTestUserA)
|
|
kit, err := service.Upsert(ctx, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
compiled, err := service.ResolveContext(ctx, kit.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, expected := range []string{"Binding creative system", "Alpha", "Signal Green", "#28C76F", "Display"} {
|
|
if !strings.Contains(compiled, expected) {
|
|
t.Fatalf("compiled context missing %q: %s", expected, compiled)
|
|
}
|
|
}
|
|
if strings.Contains(compiled, "data:") {
|
|
t.Fatal("compiled context must not include embedded asset data")
|
|
}
|
|
}
|
|
|
|
func TestProjectBrandKitBindingUsesDefaultAndExplicitNone(t *testing.T) {
|
|
store := memory.NewBrandKitStore()
|
|
brandKits := NewBrandKitService(store)
|
|
ctx := design.ContextWithUserID(context.Background(), brandKitTestUserA)
|
|
if _, err := brandKits.Upsert(ctx, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := memory.NewProjectRepository()
|
|
designs := NewDesignService(repo, nil, nil, nil, nil)
|
|
designs.SetBrandKitService(brandKits)
|
|
|
|
withDefault, err := designs.CreateProjectWithBrandKit(ctx, "Default", "", "", "", "", 0, false, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if withDefault.BrandKitID != "brand-kit-alpha" || !strings.Contains(withDefault.BrandContext, "Alpha") {
|
|
t.Fatalf("default Brand Kit was not bound: %#v", withDefault)
|
|
}
|
|
|
|
none := ""
|
|
withoutKit, err := designs.CreateProjectWithBrandKit(ctx, "None", "", "", "", "", 0, false, &none)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if withoutKit.BrandKitID != "" || withoutKit.BrandContext != "" {
|
|
t.Fatalf("explicit None should bypass the default: %#v", withoutKit)
|
|
}
|
|
|
|
bound, err := designs.UpdateProjectBrandKit(ctx, withoutKit.ID, "brand-kit-alpha")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bound.BrandKitID != "brand-kit-alpha" {
|
|
t.Fatalf("expected explicit binding, got %q", bound.BrandKitID)
|
|
}
|
|
if err := designs.ClearBrandKitBindings(ctx, "brand-kit-alpha"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleared, err := repo.Get(ctx, bound.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cleared.BrandKitID != "" {
|
|
t.Fatalf("expected binding cleanup, got %q", cleared.BrandKitID)
|
|
}
|
|
}
|
|
|
|
func TestProjectBrandKitBindingRejectsAnotherUsersKit(t *testing.T) {
|
|
brandKits := NewBrandKitService(memory.NewBrandKitStore())
|
|
ctxA := design.ContextWithUserID(context.Background(), brandKitTestUserA)
|
|
ctxB := design.ContextWithUserID(context.Background(), brandKitTestUserB)
|
|
if _, err := brandKits.Upsert(ctxA, "brand-kit-alpha", testBrandKitDocument("brand-kit-alpha", "Alpha"), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
repo := memory.NewProjectRepository()
|
|
designs := NewDesignService(repo, nil, nil, nil, nil)
|
|
designs.SetBrandKitService(brandKits)
|
|
none := ""
|
|
project, err := designs.CreateProjectAsyncWithBrandKit(ctxB, "Other user", "", "", "", "", 0, false, &none)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := designs.UpdateProjectBrandKit(ctxB, project.ID, "brand-kit-alpha"); !errors.Is(err, brandkit.ErrNotFound) {
|
|
t.Fatalf("expected ownership check to hide the Brand Kit, got %v", err)
|
|
}
|
|
stored, err := repo.Get(ctxB, project.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.BrandKitID != "" {
|
|
t.Fatalf("failed binding must not mutate the project, got %q", stored.BrandKitID)
|
|
}
|
|
}
|
|
|
|
func testBrandKitDocument(id string, name string) string {
|
|
return fmt.Sprintf(`{
|
|
"version": 1,
|
|
"id": %q,
|
|
"name": %q,
|
|
"brandName": %q,
|
|
"tagline": "Make the signal clear",
|
|
"summary": "A focused product brand.",
|
|
"audience": "Creative teams",
|
|
"positioning": "Clear and precise",
|
|
"personality": "Confident",
|
|
"voice": {"traits": "Direct", "guidance": "Lead with outcomes", "sample": "Move with clarity"},
|
|
"logos": [{"id": "logo-primary", "name": "Primary", "variant": "primary", "usage": "Light backgrounds", "source": "upload", "dataUrl": "data:image/png;base64,AA=="}],
|
|
"colorGroups": [{"id": "colors-primary", "name": "Primary", "colors": [{"id": "color-signal", "name": "Signal Green", "hex": "#28C76F", "usage": "Key actions"}]}],
|
|
"typography": [{"id": "type-display", "label": "Display", "family": "Avenir Next", "weight": "700", "sizes": "48", "description": "Headlines"}],
|
|
"referenceImages": [],
|
|
"visual": {"keywords": "precise", "composition": "strict grid", "imagery": "real product", "dos": "use space", "donts": "avoid noise", "prompt": "Keep the product clear"},
|
|
"applyToNewProjects": false,
|
|
"createdAt": "2026-01-01T00:00:00Z",
|
|
"updatedAt": "2026-01-01T00:00:00Z"
|
|
}`, id, name, name)
|
|
}
|