topfans/backend/gateway/controller/laser_generate_controller_test.go

517 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/topfans/backend/pkg/models"
)
// TestMain 已统一在 asset_controller_test.go 中声明,此处不再重复.
// (两文件同属 controller 包,TestMain 全局唯一)
// fakeLaserRepo LaserCardPersister 的 in-memory fake 实现
// 记录每次调用,便于断言
type fakeLaserRepo struct {
templates map[string]*models.LaserCardTemplate
createErr error
snapshotErr error
instances []*models.LaserCardInstance
snapshotsByID map[int64]models.MaterialsSnapshot
logs []fakeOpLog
nextInstanceID int64
}
type fakeOpLog struct {
instanceID int64
instanceNo string
userID int64
action string
statusBefore string
statusAfter string
}
func newFakeRepo() *fakeLaserRepo {
return &fakeLaserRepo{
templates: map[string]*models.LaserCardTemplate{},
snapshotsByID: map[int64]models.MaterialsSnapshot{},
}
}
func (f *fakeLaserRepo) FindTemplateByCode(code string) (*models.LaserCardTemplate, error) {
if t, ok := f.templates[code]; ok {
return t, nil
}
return nil, fmt.Errorf("template %q not found", code)
}
func (f *fakeLaserRepo) CreateInstance(inst *models.LaserCardInstance) error {
if f.createErr != nil {
return f.createErr
}
f.nextInstanceID++
inst.ID = f.nextInstanceID
inst.InstanceNo = fmt.Sprintf("LC%010d", f.nextInstanceID)
f.instances = append(f.instances, inst)
return nil
}
func (f *fakeLaserRepo) UpdateMaterialsSnapshot(instanceID int64, snapshot models.MaterialsSnapshot) error {
if f.snapshotErr != nil {
return f.snapshotErr
}
f.snapshotsByID[instanceID] = snapshot
return nil
}
func (f *fakeLaserRepo) CreateOperationLogSimple(instanceID int64, instanceNo string, userID int64, action, statusBefore, statusAfter string) error {
f.logs = append(f.logs, fakeOpLog{
instanceID: instanceID,
instanceNo: instanceNo,
userID: userID,
action: action,
statusBefore: statusBefore,
statusAfter: statusAfter,
})
return nil
}
// ============================================================
// persistGeneratedInstance 测试
// ============================================================
// 分支 1laserRepo 为 nil → 直接返回 (0, "")
func TestPersistGeneratedInstance_LaserRepoNil(t *testing.T) {
ctrl := &LaserGenerateController{} // laserRepo 是 nil
id, no := ctrl.persistGeneratedInstance(123, 456, "dream", "job-1")
if id != 0 || no != "" {
t.Errorf("expected (0, \"\"), got (%d, %q)", id, no)
}
}
// 分支 2CreateInstance 失败 → 返回 (0, ""),不写日志
func TestPersistGeneratedInstance_CreateFailed(t *testing.T) {
fake := newFakeRepo()
fake.createErr = errors.New("db connection lost")
ctrl := &LaserGenerateController{laserRepo: fake}
id, no := ctrl.persistGeneratedInstance(123, 456, "dream", "job-1")
if id != 0 || no != "" {
t.Errorf("expected (0, \"\"), got (%d, %q)", id, no)
}
if len(fake.instances) != 0 {
t.Errorf("expected 0 instances on failure, got %d", len(fake.instances))
}
if len(fake.logs) != 0 {
t.Errorf("expected 0 logs on create failure, got %d", len(fake.logs))
}
}
// 分支 3CreateInstance 成功 + 模板命中 → 返回 ID/InstanceNo + 写创建日志
func TestPersistGeneratedInstance_CreateSucceed(t *testing.T) {
fake := newFakeRepo()
fake.templates["dream"] = &models.LaserCardTemplate{ID: 5, TemplateCode: "dream"}
fake.nextInstanceID = 100
ctrl := &LaserGenerateController{laserRepo: fake}
id, no := ctrl.persistGeneratedInstance(123, 456, "dream", "job-1")
if id != 101 {
t.Errorf("expected id=101, got %d", id)
}
if no != "LC0000000101" {
t.Errorf("expected no=LC0000000101, got %q", no)
}
// 验证 instance 字段被正确填充
if len(fake.instances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(fake.instances))
}
inst := fake.instances[0]
if inst.OwnerUserID != 123 {
t.Errorf("OwnerUserID = %d, want 123", inst.OwnerUserID)
}
if inst.StarID != 456 {
t.Errorf("StarID = %d, want 456", inst.StarID)
}
if inst.TemplateCode != "dream" {
t.Errorf("TemplateCode = %q, want %q", inst.TemplateCode, "dream")
}
if inst.TemplateID != 5 {
t.Errorf("TemplateID = %d, want 5 (from FindTemplateByCode)", inst.TemplateID)
}
if inst.Status != models.LaserCardInstanceStatusRendered {
t.Errorf("Status = %q, want %q", inst.Status, models.LaserCardInstanceStatusRendered)
}
// 验证只写了 1 条创建日志statusBefore="", statusAfter="rendered"
if len(fake.logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(fake.logs))
}
got := fake.logs[0]
if got.statusBefore != "" || got.statusAfter != models.LaserCardInstanceStatusRendered {
t.Errorf("create log status wrong: before=%q after=%q", got.statusBefore, got.statusAfter)
}
if got.action != models.LaserCardActionGenerateVariants {
t.Errorf("action = %q, want %q", got.action, models.LaserCardActionGenerateVariants)
}
if got.userID != 123 {
t.Errorf("log userID = %d, want 123", got.userID)
}
}
// 分支 4模板未命中FindTemplateByCode 失败) → TemplateID 仍为 0但 instance 创建继续
func TestPersistGeneratedInstance_TemplateNotFound(t *testing.T) {
fake := newFakeRepo()
// 注意fake.templates 为空FindTemplateByCode 一定失败
fake.nextInstanceID = 50
ctrl := &LaserGenerateController{laserRepo: fake}
id, no := ctrl.persistGeneratedInstance(123, 456, "missing_template", "job-1")
// 创建应该仍然成功(模板查不到是 warn 但不阻断)
if id != 51 || no != "LC0000000051" {
t.Errorf("expected (51, LC0000000051), got (%d, %q)", id, no)
}
if len(fake.instances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(fake.instances))
}
if fake.instances[0].TemplateID != 0 {
t.Errorf("TemplateID = %d, want 0 when template not found", fake.instances[0].TemplateID)
}
}
// ============================================================
// attachMaterialsSnapshot 测试
// ============================================================
// 分支 AinstanceID <= 0 → 跳过整个流程,不写 snapshot 不写 log
func TestAttachMaterialsSnapshot_NoInstanceID(t *testing.T) {
fake := newFakeRepo()
ctrl := &LaserGenerateController{laserRepo: fake}
ctrl.attachMaterialsSnapshot(0, "LC001", 123, "cutout-url", "cutout", nil, "job-1")
if len(fake.snapshotsByID) != 0 {
t.Errorf("expected no snapshot write, got %d", len(fake.snapshotsByID))
}
if len(fake.logs) != 0 {
t.Errorf("expected no log write, got %d", len(fake.logs))
}
}
// 分支 B成功 → 写 snapshot + 写 logvariants 中 oss_key 为空的项被跳过
func TestAttachMaterialsSnapshot_Success(t *testing.T) {
fake := newFakeRepo()
ctrl := &LaserGenerateController{laserRepo: fake}
variants := []map[string]interface{}{
{"preset_id": "dream", "oss_key": "oss://dream"},
{"preset_id": "classic", "oss_key": "oss://classic"},
{"preset_id": "no_key"}, // 缺少 oss_key → 跳过
}
ctrl.attachMaterialsSnapshot(101, "LC0000000101", 123, "cutout-url", "cutout", variants, "job-1")
// 验证 snapshotcutout + 2 个有效 variant = 3 项
if len(fake.snapshotsByID) != 1 {
t.Fatalf("expected 1 snapshot, got %d", len(fake.snapshotsByID))
}
snap, ok := fake.snapshotsByID[101]
if !ok {
t.Fatalf("snapshot for instanceID=101 not found")
}
if len(snap) != 3 {
t.Fatalf("snapshot len = %d, want 3 (cutout + 2 variants, 3rd skipped)", len(snap))
}
// cutout 优先
if snap[0].Role != "cutout" || snap[0].OssKey != "cutout-url" {
t.Errorf("snapshot[0] wrong: %+v (want cutout/cutout-url)", snap[0])
}
// variants 按顺序
if snap[1].Role != "composite" || snap[1].OssKey != "oss://dream" || snap[1].PresetID != "dream" {
t.Errorf("snapshot[1] wrong: %+v", snap[1])
}
if snap[2].OssKey != "oss://classic" || snap[2].PresetID != "classic" {
t.Errorf("snapshot[2] wrong: %+v", snap[2])
}
// 验证 logstatusBefore="rendered", statusAfter=""
if len(fake.logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(fake.logs))
}
got := fake.logs[0]
if got.statusBefore != models.LaserCardInstanceStatusRendered {
t.Errorf("log statusBefore = %q, want %q", got.statusBefore, models.LaserCardInstanceStatusRendered)
}
if got.statusAfter != "" {
t.Errorf("log statusAfter = %q, want \"\"", got.statusAfter)
}
if got.userID != 123 || got.instanceNo != "LC0000000101" {
t.Errorf("log userID/no = (%d, %q), want (123, LC0000000101)", got.userID, got.instanceNo)
}
}
// 分支 Csnapshot 写失败 → 只 warn不写 log
func TestAttachMaterialsSnapshot_Failed(t *testing.T) {
fake := newFakeRepo()
fake.snapshotErr = errors.New("update conflict")
ctrl := &LaserGenerateController{laserRepo: fake}
ctrl.attachMaterialsSnapshot(101, "LC0000000101", 123, "cutout-url", "cutout", nil, "job-1")
// snapshot 写失败时log 也跳过(避免误导审计)
if len(fake.logs) != 0 {
t.Errorf("expected 0 logs on snapshot failure, got %d", len(fake.logs))
}
}
// 分支 Dempty cutoutURL + empty variants → snapshot 是空的(不应 panic
func TestAttachMaterialsSnapshot_EmptyInputs(t *testing.T) {
fake := newFakeRepo()
ctrl := &LaserGenerateController{laserRepo: fake}
ctrl.attachMaterialsSnapshot(101, "LC0000000101", 123, "", "cutout", nil, "job-1")
snap := fake.snapshotsByID[101]
if len(snap) != 0 {
t.Errorf("expected empty snapshot, got %d items", len(snap))
}
// 仍然写 1 条 log记录 snapshot 已更新,即使内容为空)
if len(fake.logs) != 1 {
t.Errorf("expected 1 log even for empty snapshot, got %d", len(fake.logs))
}
}
// ============================================================
// 集成场景Dify 路径完整流程persist + attach
// ============================================================
func TestPersistAndAttach_DifyFlow(t *testing.T) {
fake := newFakeRepo()
fake.templates["dream"] = &models.LaserCardTemplate{ID: 5, TemplateCode: "dream"}
fake.nextInstanceID = 200
ctrl := &LaserGenerateController{laserRepo: fake}
// Step 1: 持久化 instanceDify 路径同步执行)
id, no := ctrl.persistGeneratedInstance(123, 456, "dream", "job-dify-1")
if id == 0 || no == "" {
t.Fatalf("persist failed: id=%d, no=%q", id, no)
}
// Step 2: 写入 materials_snapshot
variants := []map[string]interface{}{
{"preset_id": "dream", "oss_key": "oss://dream"},
{"preset_id": "classic", "oss_key": "oss://classic"},
}
ctrl.attachMaterialsSnapshot(id, no, 123, "cutout-url", "cutout", variants, "job-dify-1")
// 验证最终状态:
// - 1 个 instance
// - 1 个 snapshot3 项cutout + 2 variants
// - 2 条 op logcreate + snapshot update
if len(fake.instances) != 1 {
t.Errorf("instances = %d, want 1", len(fake.instances))
}
if len(fake.snapshotsByID) != 1 {
t.Errorf("snapshots = %d, want 1", len(fake.snapshotsByID))
}
if len(fake.logs) != 2 {
t.Fatalf("logs = %d, want 2 (create + snapshot)", len(fake.logs))
}
// log 顺序:先 createempty → rendered后 snapshotrendered → empty
if fake.logs[0].statusBefore != "" || fake.logs[0].statusAfter != "rendered" {
t.Errorf("log[0] should be create: got before=%q after=%q",
fake.logs[0].statusBefore, fake.logs[0].statusAfter)
}
if fake.logs[1].statusBefore != "rendered" || fake.logs[1].statusAfter != "" {
t.Errorf("log[1] should be snapshot: got before=%q after=%q",
fake.logs[1].statusBefore, fake.logs[1].statusAfter)
}
}
// 集成场景MiniMax 异步路径(创建失败时,提交响应仍能正常返回)
func TestPersistGeneratedInstance_MiniMaxSubmitResponseStable(t *testing.T) {
fake := newFakeRepo()
fake.createErr = errors.New("db transient")
ctrl := &LaserGenerateController{laserRepo: fake}
// 即使创建失败,调用方仍拿到 (0, "") 而不是 panic
id, no := ctrl.persistGeneratedInstance(1, 2, "dream", "job-mm-1")
if id != 0 || no != "" {
t.Errorf("expected (0, \"\") on failure, got (%d, %q)", id, no)
}
// 后续 attach 即使被调用也是 no-op
ctrl.attachMaterialsSnapshot(id, no, 1, "", "cutout", nil, "job-mm-1")
if len(fake.snapshotsByID) != 0 {
t.Errorf("expected no snapshot when instanceID=0, got %d", len(fake.snapshotsByID))
}
}
// ============================================================
// buildMaterialsSnapshot 单元测试(纯函数)
// ============================================================
func TestBuildMaterialsSnapshot(t *testing.T) {
tests := []struct {
name string
cutout string
variants []map[string]interface{}
wantLen int
}{
{
name: "empty inputs",
cutout: "",
variants: nil,
wantLen: 0,
},
{
name: "only cutout",
cutout: "oss://cutout",
variants: nil,
wantLen: 1,
},
{
name: "variants with empty oss_key are skipped",
cutout: "",
variants: []map[string]interface{}{{"preset_id": "x", "oss_key": ""}, {"preset_id": "y"}},
wantLen: 0,
},
{
name: "mixed valid",
cutout: "oss://cutout",
variants: []map[string]interface{}{
{"preset_id": "dream", "oss_key": "oss://dream"},
{"preset_id": "classic", "oss_key": ""}, // skipped
{"preset_id": "ice", "oss_key": "oss://ice"},
},
wantLen: 3, // cutout + dream + ice
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
snap := buildMaterialsSnapshot(tt.cutout, "cutout", tt.variants)
if len(snap) != tt.wantLen {
t.Errorf("len = %d, want %d (snap=%+v)", len(snap), tt.wantLen, snap)
}
})
}
}
// ============================================================
// enrichRenderConfigsWithUserPrompt 单元测试
// 设计目的:userPrompt 加权策略不能破坏现有 render_configs 结构,
// 必须保证 bg_prompt 不出现 userPrompt 的 3 次重复(只能 0 / 1 / 2 次)
// ============================================================
// 分支 1:userPrompt 为空 → 原样返回,不动 bg_prompt
func TestEnrichRenderConfigsWithUserPrompt_Empty(t *testing.T) {
input := []map[string]interface{}{
{"preset_id": "dream", "bg_prompt": "原始 prompt"},
}
got := enrichRenderConfigsWithUserPrompt(input, "")
var arr []map[string]interface{}
if err := json.Unmarshal(got, &arr); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(arr) != 1 || arr[0]["bg_prompt"] != "原始 prompt" {
t.Errorf("empty userPrompt should not modify bg_prompt, got %+v", arr)
}
}
// 分支 2:bg_prompt 为空 → 以「主题(必须遵循,不可偏离)」开头建立主题锚点
func TestEnrichRenderConfigsWithUserPrompt_EmptyBgPrompt(t *testing.T) {
input := []map[string]interface{}{
{"preset_id": "dream", "bg_prompt": ""},
}
got := enrichRenderConfigsWithUserPrompt(input, "海报风格")
bg := extractBgPrompt(t, got, 0)
if !strings.Contains(bg, "主题(必须遵循,不可偏离)") || !strings.Contains(bg, "海报风格") {
t.Errorf("expected 主题(必须遵循,不可偏离) prefix + 海报风格, got %q", bg)
}
}
// 分支 3:bg_prompt 已包含 userPrompt(前端已加权)→ 跳过,避免 3 次重复
func TestEnrichRenderConfigsWithUserPrompt_AlreadyWeighted(t *testing.T) {
weighted := "主题(必须遵循,不可偏离): 海报风格. 主题再强调: 海报风格"
input := []map[string]interface{}{
{"preset_id": "dream", "bg_prompt": weighted},
}
got := enrichRenderConfigsWithUserPrompt(input, "海报风格")
bg := extractBgPrompt(t, got, 0)
if bg != weighted {
t.Errorf("already-weighted bg_prompt should be unchanged, got %q", bg)
}
count := strings.Count(bg, "海报风格")
if count > 2 {
t.Errorf("expected at most 2 occurrences, got %d (bg=%q)", count, bg)
}
}
// 分支 4:bg_prompt 不包含 userPrompt(旧调用方未加权)→ 末尾追加一次 userPrompt
func TestEnrichRenderConfigsWithUserPrompt_AppendOnce(t *testing.T) {
input := []map[string]interface{}{
{"preset_id": "dream", "bg_prompt": "some existing prompt"},
}
got := enrichRenderConfigsWithUserPrompt(input, "赛博朋克")
bg := extractBgPrompt(t, got, 0)
if !strings.HasPrefix(bg, "some existing prompt") {
t.Errorf("expected original prefix preserved, got %q", bg)
}
if !strings.HasSuffix(bg, "赛博朋克") && !strings.Contains(bg, "主题强调: 赛博朋克") {
t.Errorf("expected userPrompt appended with 主题强调 prefix, got %q", bg)
}
if strings.Count(bg, "赛博朋克") != 1 {
t.Errorf("expected exactly 1 occurrence, got %d (bg=%q)", strings.Count(bg, "赛博朋克"), bg)
}
}
// 分支 5:多个 variant 同时加权,每个 bg_prompt 都要正确处理
func TestEnrichRenderConfigsWithUserPrompt_MultipleVariants(t *testing.T) {
input := []map[string]interface{}{
{"preset_id": "dream", "bg_prompt": ""},
{"preset_id": "classic", "bg_prompt": "主题(必须遵循,不可偏离): 海报风格"},
{"preset_id": "ice", "bg_prompt": "old prompt"},
}
got := enrichRenderConfigsWithUserPrompt(input, "海报风格")
var arr []map[string]interface{}
if err := json.Unmarshal(got, &arr); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(arr) != 3 {
t.Fatalf("expected 3 variants, got %d", len(arr))
}
// dream: 空 bg → 主题锚点
if !strings.Contains(arr[0]["bg_prompt"].(string), "主题(必须遵循,不可偏离)") {
t.Errorf("dream should have 主题 prefix, got %q", arr[0]["bg_prompt"])
}
// classic: 已被前端加权 → 不变
if arr[1]["bg_prompt"].(string) != "主题(必须遵循,不可偏离): 海报风格" {
t.Errorf("classic should be unchanged, got %q", arr[1]["bg_prompt"])
}
// ice: 未加权 → 末尾追加
if !strings.Contains(arr[2]["bg_prompt"].(string), "主题强调: 海报风格") {
t.Errorf("ice should have 主题强调 suffix, got %q", arr[2]["bg_prompt"])
}
}
// extractBgPrompt helper:从序列化的 render_configs JSON 里提取第 i 个 variant 的 bg_prompt
func extractBgPrompt(t *testing.T, data []byte, idx int) string {
t.Helper()
var arr []map[string]interface{}
if err := json.Unmarshal(data, &arr); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if idx >= len(arr) {
t.Fatalf("idx %d out of range (len=%d)", idx, len(arr))
}
bg, _ := arr[idx]["bg_prompt"].(string)
return bg
}