fix(*): 画布生成与邮件 - 合并 PR #423 并修复恢复关联和规格报价
保留 PR 原始提交,合并待测试文档双方内容。补齐摘要 nodeId 消费端,修复缺省质量的 <nil> 匹配,并移除报价面板按价格档篡改自动质量的行为。 验证:前端完整测试脚本 1139 项通过,生产构建通过;后端相关专项通过。全量后端存在 4 项基线可复现失败;另一次资源清理测试出现 SQLite 表锁,合并分支与基线单独重跑通过。未执行真实 SMTP、浏览器端到端或收费生成。
This commit is contained in:
@@ -195,9 +195,8 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
|
||||
if err := s.repo.Create(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
brandName := s.appearanceBrandName()
|
||||
setting = resolveEmailSender(setting, brandName)
|
||||
if err := s.deliverEmail(setting, email, brandName+"注册验证码", registrationEmailBody(brandName, code)); err != nil {
|
||||
setting = resolveEmailSender(setting, s.appearanceBrandName())
|
||||
if err := s.deliverEmail(setting, email, setting.FromName+"注册验证码", registrationEmailBody(setting.FromName, code)); err != nil {
|
||||
cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID)
|
||||
if cleanupErr != nil {
|
||||
return errors.Join(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
@@ -10,6 +13,72 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestImageSpecificationQuoteAgreesWithTaskBilling(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
ID: "image-channel-model", ChannelID: "image-channel", ModelKey: "image-model", Capability: "image",
|
||||
Protocol: model.ChannelInterfaceOpenAIImage, Enabled: true, PriceConfigured: true,
|
||||
}
|
||||
for operationIndex, operation := range []string{"text_to_image", "image_to_image"} {
|
||||
for index, quality := range []string{"1k", "2k", "4k"} {
|
||||
selector := fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality)
|
||||
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
|
||||
ID: operation + "-" + quality, ChannelModelID: channelModel.ID, SelectorKey: selector, SelectorJSON: selector,
|
||||
Resolution: "*", BillingMode: "fixed_request", UnitPriceMicrocredits: int64(index+1+operationIndex*3) * 1_000_000,
|
||||
Enabled: true, PriceConfigured: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := db.Create(&channelModel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&channelModel.PriceTiers).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := CapabilitySpec{Version: 1, Capability: "image", Inputs: map[string]InputConstraint{"image": {Max: 9}}, Options: map[string]OptionConstraint{"quality": {Values: []any{"1k", "2k", "4k"}}}}
|
||||
cached := cachedLogicalModel{
|
||||
Model: model.LogicalModel{ID: "logical-image", PricePolicy: "channel", Capability: "image"},
|
||||
ProductSpec: spec, Defaults: map[string]any{"quality": "2k"},
|
||||
Routes: []cachedLogicalRoute{{Route: model.LogicalModelRoute{ID: "image-route", Enabled: true, Weight: 1}, CapabilitySpec: spec, ChannelModel: channelModel}},
|
||||
}
|
||||
svc := &Service{repo: repository.New(db), routeCatalogTTL: time.Hour, routeCatalog: &routeCatalogSnapshot{LoadedAt: time.Now(), Models: map[string]cachedLogicalModel{"logical-image": cached}}}
|
||||
for _, tier := range channelModel.PriceTiers {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
imageCount := 0
|
||||
if selector["operation"] == "image_to_image" {
|
||||
imageCount = 1
|
||||
}
|
||||
input := map[string]any{"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{
|
||||
"channelId": channelModel.ChannelID, "model": channelModel.ModelKey, "quality": strings.ToUpper(selector["quality"]),
|
||||
}}
|
||||
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
|
||||
quote, err := svc.QuoteLogicalModel("logical-image", intent)
|
||||
if err != nil {
|
||||
t.Fatalf("quote %s: %v", tier.ID, err)
|
||||
}
|
||||
order, err := svc.taskBillingOrder("user", &model.Task{ID: "task", Type: "canvas_image", Operation: "image"}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("billing %s: %v", tier.ID, err)
|
||||
}
|
||||
if quote.AmountMicrocredits != tier.UnitPriceMicrocredits || order.AmountMicrocredits != quote.AmountMicrocredits || order.PriceTierID != tier.ID {
|
||||
t.Fatalf("tier %s: quote=%#v order=%#v", tier.ID, quote, order)
|
||||
}
|
||||
}
|
||||
quote, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image"})
|
||||
if err != nil || quote.AmountMicrocredits != 2_000_000 {
|
||||
t.Fatalf("default resolution quote=%#v error=%v", quote, err)
|
||||
}
|
||||
if _, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); err == nil {
|
||||
t.Fatal("unconfigured resolution must not be quoted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -85,7 +85,14 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
|
||||
}
|
||||
|
||||
func normalizeModelRequestOption(name string, value any) any {
|
||||
if canonicalCapabilityOptionName(name) != "vquality" {
|
||||
canonicalName := canonicalCapabilityOptionName(name)
|
||||
if canonicalName == "quality" || canonicalName == "size" {
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.ToLower(strings.TrimSpace(text))
|
||||
}
|
||||
return value
|
||||
}
|
||||
if canonicalName != "vquality" {
|
||||
return value
|
||||
}
|
||||
resolution, ok := value.(string)
|
||||
@@ -730,8 +737,22 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
selector["videoSeconds"] = strconv.Itoa(seconds)
|
||||
}
|
||||
case "image":
|
||||
if intent.Inputs["image"] > 0 {
|
||||
selector["operation"] = "image_to_image"
|
||||
} else {
|
||||
selector["operation"] = "text_to_image"
|
||||
}
|
||||
rawQuality, _ := intent.Options["quality"].(string)
|
||||
rawSize, _ := intent.Options["size"].(string)
|
||||
if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" {
|
||||
selector["quality"] = quality
|
||||
}
|
||||
for _, key := range []string{"quality", "size"} {
|
||||
if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" {
|
||||
if key == "quality" && selector["quality"] != "" {
|
||||
continue
|
||||
}
|
||||
text, _ := intent.Options[key].(string)
|
||||
if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" {
|
||||
selector[key] = value
|
||||
}
|
||||
}
|
||||
@@ -739,6 +760,33 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
return selector
|
||||
}
|
||||
|
||||
func normalizeImagePriceQuality(rawQuality string, rawSize string) string {
|
||||
quality := strings.ToLower(strings.TrimSpace(rawQuality))
|
||||
if quality != "" && quality != "auto" && quality != "any" {
|
||||
return quality
|
||||
}
|
||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
|
||||
return ""
|
||||
}
|
||||
pixels := width * height
|
||||
switch {
|
||||
case pixels <= 2_000_000:
|
||||
return "1k"
|
||||
case pixels <= 4_300_000:
|
||||
return "2k"
|
||||
case pixels <= 8_294_400:
|
||||
return "4k"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
if len(selector) == 0 {
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) {
|
||||
channelModel := model.ChannelModel{}
|
||||
for _, operation := range []string{"text_to_image", "image_to_image"} {
|
||||
for _, quality := range []string{"1k", "2k", "4k"} {
|
||||
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
|
||||
ID: operation + "-" + quality, SelectorJSON: fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality), Enabled: true, PriceConfigured: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{"", "image", "text_to_image", "image_to_image"} {
|
||||
for _, quality := range []string{"1K", "2K", "4K"} {
|
||||
for _, imageCount := range []int{0, 1, 3} {
|
||||
intent := ModelRequestIntentFromTaskInput(map[string]any{
|
||||
"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{"quality": quality},
|
||||
}, "canvas_image", operation)
|
||||
wantOperation := "text_to_image"
|
||||
if imageCount > 0 {
|
||||
wantOperation = "image_to_image"
|
||||
}
|
||||
tier := channelModelPriceTierForIntent(channelModel, intent)
|
||||
if tier == nil || tier.ID != wantOperation+"-"+skuSelectorForIntent(intent)["quality"] {
|
||||
t.Fatalf("operation=%q quality=%q imageCount=%d: tier=%#v", operation, quality, imageCount, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tier := channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); tier != nil {
|
||||
t.Fatalf("unconfigured resolution matched tier: %#v", tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "video",
|
||||
@@ -17,6 +49,55 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRequestIntentNormalizesImageSpecificationValues(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "image",
|
||||
"config": map[string]any{"quality": "1K", "size": "3:2"},
|
||||
}
|
||||
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
|
||||
if got := intent.Options["quality"]; got != "1k" {
|
||||
t.Fatalf("quality = %#v, want 1k", got)
|
||||
}
|
||||
if got := intent.Options["size"]; got != "3:2" {
|
||||
t.Fatalf("size = %#v, want 3:2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorInfersImageResolutionFromSizeWhenQualityIsAutomatic(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
size string
|
||||
want string
|
||||
}{
|
||||
{size: "1024x1024", want: "1k"},
|
||||
{size: "2048x2048", want: "2k"},
|
||||
{size: "2880x2880", want: "4k"},
|
||||
} {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "auto", "size": test.size}})
|
||||
if selector["quality"] != test.want {
|
||||
t.Fatalf("size %s quality = %q, want %q", test.size, selector["quality"], test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorHandlesMissingImageOptions(t *testing.T) {
|
||||
for _, quality := range []any{nil, "", "auto", "any"} {
|
||||
options := map[string]any{"size": "2048x2048"}
|
||||
if quality != nil {
|
||||
options["quality"] = quality
|
||||
}
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: options})
|
||||
if selector["quality"] != "2k" {
|
||||
t.Errorf("quality=%#v: selector=%#v, want 2k", quality, selector)
|
||||
}
|
||||
}
|
||||
for _, options := range []map[string]any{nil, {}, {"quality": nil, "size": nil}} {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: options})
|
||||
if len(selector) != 1 || selector["operation"] != "text_to_image" {
|
||||
t.Errorf("missing options produced synthetic specification: %#v", selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
|
||||
if selector["imageCount"] != "5" || selector["vquality"] != "720p" {
|
||||
|
||||
@@ -74,9 +74,8 @@ func (s *Service) SendPasswordResetEmailCode(rawEmail string) error {
|
||||
if err := s.repo.Create(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
brandName := s.appearanceBrandName()
|
||||
setting = resolveEmailSender(setting, brandName)
|
||||
if err := s.deliverEmail(setting, email, brandName+"密码重置验证码", passwordResetEmailBody(brandName, code)); err != nil {
|
||||
setting = resolveEmailSender(setting, s.appearanceBrandName())
|
||||
if err := s.deliverEmail(setting, email, setting.FromName+"密码重置验证码", passwordResetEmailBody(setting.FromName, code)); err != nil {
|
||||
if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil {
|
||||
log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr)
|
||||
}
|
||||
|
||||
@@ -188,8 +188,38 @@ func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomAppearanceBrandsRegistrationAndPasswordResetEmails(t *testing.T) {
|
||||
func TestSenderNameBrandsRegistrationAndPasswordResetEmails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fromName string
|
||||
wantName string
|
||||
}{
|
||||
{name: "default inherits appearance", fromName: defaultAppearanceBrandName, wantName: "HIMA Studio"},
|
||||
{name: "empty inherits appearance", fromName: "", wantName: "HIMA Studio"},
|
||||
{name: "custom sender overrides appearance", fromName: "Custom Canvas", wantName: "Custom Canvas"},
|
||||
{name: "custom sender is trimmed", fromName: " 自定义工作台 ", wantName: "自定义工作台"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertRegistrationAndPasswordResetEmailBrand(t, tc.fromName, tc.wantName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertRegistrationAndPasswordResetEmailBrand(t *testing.T, fromName, wantName string) {
|
||||
t.Helper()
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
_, emailSetting, err := svc.readEmailSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emailSetting.FromName = fromName
|
||||
settingJSON, err := json.Marshal(emailSetting)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", string(settingJSON)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appearanceJSON, err := json.Marshal(AppearanceSetting{
|
||||
SchemaVersion: appearanceSchemaVersion,
|
||||
BrandName: "HIMA Studio",
|
||||
@@ -238,13 +268,16 @@ func TestCustomAppearanceBrandsRegistrationAndPasswordResetEmails(t *testing.T)
|
||||
t.Fatalf("deliveries = %d, want 2", len(deliveries))
|
||||
}
|
||||
for _, delivered := range deliveries {
|
||||
if delivered.fromName != "HIMA Studio" || !strings.Contains(delivered.subject, "HIMA Studio") || !strings.Contains(delivered.body, "HIMA Studio") || strings.Contains(delivered.subject, defaultAppearanceBrandName) || strings.Contains(delivered.body, defaultAppearanceBrandName) {
|
||||
t.Fatalf("email did not inherit custom appearance: %#v", delivered)
|
||||
if delivered.fromName != wantName || !strings.Contains(delivered.subject, wantName) || !strings.Contains(delivered.body, wantName) || strings.Contains(delivered.subject, defaultAppearanceBrandName) || strings.Contains(delivered.body, defaultAppearanceBrandName) {
|
||||
t.Fatalf("email did not use resolved sender name %q: %#v", wantName, delivered)
|
||||
}
|
||||
}
|
||||
if deliveries[0].subject != "HIMA Studio注册验证码" || deliveries[1].subject != "HIMA Studio密码重置验证码" {
|
||||
if deliveries[0].subject != wantName+"注册验证码" || deliveries[1].subject != wantName+"密码重置验证码" {
|
||||
t.Fatalf("unexpected branded subjects: %#v", deliveries)
|
||||
}
|
||||
if !strings.Contains(deliveries[0].body, "你正在注册"+wantName+"。") {
|
||||
t.Fatalf("registration body did not use resolved sender name: %q", deliveries[0].body)
|
||||
}
|
||||
}
|
||||
|
||||
func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
|
||||
@@ -42,6 +42,7 @@ type TaskSummary struct {
|
||||
}
|
||||
|
||||
type TaskClientContext struct {
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
BatchIndex int `json:"batchIndex,omitempty"`
|
||||
@@ -136,6 +137,7 @@ func taskClientContext(raw string) *TaskClientContext {
|
||||
var input struct {
|
||||
Metadata struct {
|
||||
Source string `json:"source"`
|
||||
NodeID string `json:"nodeId"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
MessageID string `json:"messageId"`
|
||||
BatchIndex int `json:"batchIndex"`
|
||||
@@ -152,16 +154,20 @@ func taskClientContext(raw string) *TaskClientContext {
|
||||
return nil
|
||||
}
|
||||
metadata := input.Metadata
|
||||
context := &TaskClientContext{NodeID: metadata.NodeID}
|
||||
if metadata.Source == "create-page" && metadata.ConversationID != "" && metadata.MessageID != "" {
|
||||
return &TaskClientContext{
|
||||
ConversationID: metadata.ConversationID,
|
||||
MessageID: metadata.MessageID,
|
||||
BatchIndex: metadata.BatchIndex,
|
||||
BatchCount: metadata.BatchCount,
|
||||
}
|
||||
context.ConversationID = metadata.ConversationID
|
||||
context.MessageID = metadata.MessageID
|
||||
context.BatchIndex = metadata.BatchIndex
|
||||
context.BatchCount = metadata.BatchCount
|
||||
return context
|
||||
}
|
||||
if metadata.ShotID != "" && metadata.WorkflowStepID != "" {
|
||||
return &TaskClientContext{DomainProjectID: metadata.DomainProjectID, ShotID: metadata.ShotID, WorkflowStepID: metadata.WorkflowStepID, ArtifactType: metadata.ArtifactType}
|
||||
context.DomainProjectID = metadata.DomainProjectID
|
||||
context.ShotID = metadata.ShotID
|
||||
context.WorkflowStepID = metadata.WorkflowStepID
|
||||
context.ArtifactType = metadata.ArtifactType
|
||||
return context
|
||||
}
|
||||
chapterOperation := ""
|
||||
if metadata.Operation == "chapter_character_breakdown" {
|
||||
@@ -170,13 +176,15 @@ func taskClientContext(raw string) *TaskClientContext {
|
||||
chapterOperation = "storyboard"
|
||||
}
|
||||
if chapterOperation == "" || metadata.DomainProjectID == "" || metadata.ChapterID == "" {
|
||||
return nil
|
||||
}
|
||||
return &TaskClientContext{
|
||||
DomainProjectID: metadata.DomainProjectID,
|
||||
ChapterID: metadata.ChapterID,
|
||||
ChapterOperation: chapterOperation,
|
||||
if context.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
return context
|
||||
}
|
||||
context.DomainProjectID = metadata.DomainProjectID
|
||||
context.ChapterID = metadata.ChapterID
|
||||
context.ChapterOperation = chapterOperation
|
||||
return context
|
||||
}
|
||||
|
||||
// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。
|
||||
|
||||
@@ -61,6 +61,37 @@ func TestTaskClientContextRequiresCreatePageMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskClientContextPreservesCanvasNodeID(t *testing.T) {
|
||||
context := taskClientContext(`{"mode":"image","metadata":{"nodeId":"canvas-node-1","source":"canvas"}}`)
|
||||
if context == nil || context.NodeID != "canvas-node-1" {
|
||||
t.Fatalf("canvas node context was not preserved: %+v", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSummaryPreservesCanvasNodeWithoutExposingInput(t *testing.T) {
|
||||
summary := taskSummaryForOutput(model.Task{
|
||||
ID: "task-1", ProjectID: "project-1", Type: "canvas_image",
|
||||
InputJSON: `{"metadata":{"nodeId":"node-1"},"config":{"apiKey":"private-test-value"}}`,
|
||||
})
|
||||
encoded, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output map[string]any
|
||||
if err := json.Unmarshal(encoded, &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, ok := output["clientContext"].(map[string]any)
|
||||
if !ok || context["nodeId"] != "node-1" || len(context) != 1 {
|
||||
t.Fatalf("summary lost safe canvas association: %#v", output)
|
||||
}
|
||||
for _, key := range []string{"inputJson", "config", "apiKey"} {
|
||||
if _, exists := output[key]; exists {
|
||||
t.Fatalf("summary leaked %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskClientContextProjectsChapterOperations(t *testing.T) {
|
||||
characters := taskClientContext(`{"metadata":{"domainProjectId":"project-1","chapterId":"chapter-1","operation":"chapter_character_breakdown"}}`)
|
||||
if characters == nil || characters.DomainProjectID != "project-1" || characters.ChapterID != "chapter-1" || characters.ChapterOperation != "characters" {
|
||||
|
||||
Reference in New Issue
Block a user