feat(server): add brand kit management and project binding
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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user