701 lines
26 KiB
Go
701 lines
26 KiB
Go
package app
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/database"
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/repository"
|
||
|
||
"gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func TestAnnouncementPublishReadAndCloseLifecycle(t *testing.T) {
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB.SetMaxOpenConns(1)
|
||
if err := db.AutoMigrate(&model.User{}, &model.Announcement{}, &model.UserAnnouncementRead{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := New(repository.New(db), t.TempDir())
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||
|
||
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "服务恢复", Content: "视频模型已经恢复正常使用。", Level: model.AnnouncementLevelSuccess})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if announcement.Status != model.AnnouncementStatusActive {
|
||
t.Fatalf("status = %q, want active", announcement.Status)
|
||
}
|
||
|
||
feed, err := svc.UserAnnouncements(user)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(feed.Announcements) != 1 || feed.UnreadCount != 1 {
|
||
t.Fatalf("feed = %+v, want one unread announcement", feed)
|
||
}
|
||
if _, err := svc.MarkAnnouncementsRead(user, []string{announcement.ID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
feed, err = svc.UserAnnouncements(user)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if feed.UnreadCount != 0 {
|
||
t.Fatalf("unread count = %d, want 0", feed.UnreadCount)
|
||
}
|
||
|
||
closed, err := svc.CloseAnnouncement(admin, announcement.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if closed.Status != model.AnnouncementStatusClosed || closed.ClosedAt == nil {
|
||
t.Fatalf("closed announcement = %+v", closed)
|
||
}
|
||
feed, err = svc.UserAnnouncements(user)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(feed.Announcements) != 0 || feed.UnreadCount != 0 {
|
||
t.Fatalf("closed announcement should not remain in user feed: %+v", feed)
|
||
}
|
||
}
|
||
|
||
func TestAnnouncementUpdateRepublishesAndResetsReads(t *testing.T) {
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB.SetMaxOpenConns(1)
|
||
if err := db.AutoMigrate(&model.User{}, &model.Announcement{}, &model.UserAnnouncementRead{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := New(repository.New(db), t.TempDir())
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||
|
||
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "旧标题", Content: "旧正文", Level: model.AnnouncementLevelInfo})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := svc.MarkAnnouncementsRead(user, []string{announcement.ID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := svc.CloseAnnouncement(admin, announcement.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{Title: "新标题", Content: "新正文", Level: model.AnnouncementLevelWarning, Pinned: true})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if updated.Status != model.AnnouncementStatusActive || updated.ClosedAt != nil || updated.Title != "新标题" || updated.Content != "新正文" || updated.Level != model.AnnouncementLevelWarning || !updated.Pinned {
|
||
t.Fatalf("updated announcement = %+v", updated)
|
||
}
|
||
feed, err := svc.UserAnnouncements(user)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(feed.Announcements) != 1 || feed.UnreadCount != 1 || feed.Announcements[0].Content != "新正文" {
|
||
t.Fatalf("feed after republish = %+v, want one unread updated announcement", feed)
|
||
}
|
||
}
|
||
|
||
func TestPinnedAnnouncementsAreReturnedBeforeNewerRegularAnnouncements(t *testing.T) {
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB.SetMaxOpenConns(1)
|
||
if err := db.AutoMigrate(&model.User{}, &model.Announcement{}, &model.UserAnnouncementRead{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := New(repository.New(db), t.TempDir())
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||
|
||
pinned, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "置顶", Content: "置顶公告", Level: model.AnnouncementLevelWarning, Pinned: true})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
regular, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "普通", Content: "较新的普通公告", Level: model.AnnouncementLevelInfo})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Model(&model.Announcement{}).Where("id = ?", pinned.ID).Update("published_at", regular.PublishedAt.Add(-time.Hour)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
feed, err := svc.UserAnnouncements(user)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(feed.Announcements) != 2 || feed.Announcements[0].ID != pinned.ID || !feed.Announcements[0].Pinned {
|
||
t.Fatalf("feed = %+v, want pinned announcement first", feed.Announcements)
|
||
}
|
||
page, err := svc.AdminAnnouncementPage(admin, AdminListQuery{Page: 1, Limit: 20})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(page.Announcements) != 2 || page.Announcements[0].ID != pinned.ID {
|
||
t.Fatalf("admin page = %+v, want pinned announcement first", page.Announcements)
|
||
}
|
||
}
|
||
|
||
func TestAnnouncementImageDraftIsConsumedWhenAnnouncementIsCreated(t *testing.T) {
|
||
svc, db := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
resource := createAnnouncementImageDraft(t, db, admin.ID, "image-draft")
|
||
|
||
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{
|
||
Title: "带图公告", Content: "公告正文", ImageResourceID: resource.ID, Level: model.AnnouncementLevelInfo,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if announcement.ImageResourceID != resource.ID || announcement.ImageURL != "/api/announcements/"+announcement.ID+"/image" {
|
||
t.Fatalf("announcement image = %+v", announcement)
|
||
}
|
||
var draftCount int64
|
||
if err := db.Model(&model.AnnouncementImageDraft{}).Where("resource_id = ?", resource.ID).Count(&draftCount).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if draftCount != 0 {
|
||
t.Fatalf("draft count = %d, want 0", draftCount)
|
||
}
|
||
}
|
||
|
||
func TestAnnouncementImageReplacementQueuesOldResourceDeletionAtomically(t *testing.T) {
|
||
svc, db := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
oldResource := createAnnouncementImageDraft(t, db, admin.ID, "old-image")
|
||
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{
|
||
Title: "旧公告", Content: "旧正文", ImageResourceID: oldResource.ID, Level: model.AnnouncementLevelInfo,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
newResource := createAnnouncementImageDraft(t, db, admin.ID, "new-image")
|
||
|
||
updated, err := svc.UpdateAnnouncement(admin, announcement.ID, UpdateAnnouncementRequest{
|
||
Title: "新公告", Content: "新正文", ImageResourceID: newResource.ID, Level: model.AnnouncementLevelWarning, Pinned: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if updated.ImageResourceID != newResource.ID || updated.ImageURL == "" {
|
||
t.Fatalf("updated announcement = %+v", updated)
|
||
}
|
||
var oldResourceCount int64
|
||
if err := db.Model(&model.Resource{}).Where("id = ?", oldResource.ID).Count(&oldResourceCount).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if oldResourceCount != 0 {
|
||
t.Fatalf("old resource count = %d, want 0", oldResourceCount)
|
||
}
|
||
var deletionJobs []model.ResourceDeletionJob
|
||
if err := db.Where("resource_id = ?", oldResource.ID).Find(&deletionJobs).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(deletionJobs) != 1 || deletionJobs[0].ObjectKey != oldResource.ObjectKey {
|
||
t.Fatalf("deletion jobs = %+v", deletionJobs)
|
||
}
|
||
var draftCount int64
|
||
if err := db.Model(&model.AnnouncementImageDraft{}).Where("resource_id = ?", newResource.ID).Count(&draftCount).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if draftCount != 0 {
|
||
t.Fatalf("new image draft count = %d, want 0", draftCount)
|
||
}
|
||
}
|
||
|
||
func TestDiscardAnnouncementImageDraftRemovesRecordAndQueuesDeletion(t *testing.T) {
|
||
svc, db := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
resource := createAnnouncementImageDraft(t, db, admin.ID, "cancelled-image")
|
||
|
||
if err := svc.DiscardAnnouncementImage(admin, resource.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var resourceCount int64
|
||
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if resourceCount != 0 {
|
||
t.Fatalf("resource count = %d, want 0", resourceCount)
|
||
}
|
||
var jobCount int64
|
||
if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if jobCount != 1 {
|
||
t.Fatalf("deletion job count = %d, want 1", jobCount)
|
||
}
|
||
}
|
||
|
||
func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) {
|
||
svc := &Service{}
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
if _, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "", Content: "正文", Level: model.AnnouncementLevelInfo}); err == nil {
|
||
t.Fatal("expected blank title to be rejected")
|
||
}
|
||
if _, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "标题", Content: "正文", Level: "unknown"}); err == nil {
|
||
t.Fatal("expected invalid level to be rejected")
|
||
}
|
||
}
|
||
|
||
func TestAnnouncementPublishAllowsEmptyContent(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "仅标题公告", Content: " ", Level: model.AnnouncementLevelInfo})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if announcement.Title != "仅标题公告" || announcement.Content != "" {
|
||
t.Fatalf("announcement = %+v", announcement)
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementLifecycle(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
|
||
Title: "常驻公告1",
|
||
Status: "active",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if banner.Title != "常驻公告1" || banner.Status != "active" {
|
||
t.Fatalf("banner = %+v", banner)
|
||
}
|
||
|
||
activeBanners, err := svc.ActiveBannerAnnouncements()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(activeBanners) != 1 || activeBanners[0].ID != banner.ID {
|
||
t.Fatalf("activeBanners = %+v", activeBanners)
|
||
}
|
||
|
||
updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
|
||
Title: "更新的常驻公告",
|
||
Link: "/projects",
|
||
Status: "disabled",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if updated.Title != "更新的常驻公告" || updated.Link != "/projects" || updated.Status != "disabled" {
|
||
t.Fatalf("updated = %+v", updated)
|
||
}
|
||
|
||
activeAfterDisable, _ := svc.ActiveBannerAnnouncements()
|
||
if len(activeAfterDisable) != 0 {
|
||
t.Fatalf("expected 0 active banners, got %d", len(activeAfterDisable))
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementAcceptsFutureStartAndLinkKinds(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
future := time.Now().Add(24 * time.Hour)
|
||
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
|
||
Title: "预约明天上线",
|
||
Link: "https://example.com/news/1",
|
||
Status: "active",
|
||
StartsAt: &future,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("future start should be accepted: %v", err)
|
||
}
|
||
if banner.Link != "https://example.com/news/1" || banner.StartsAt == nil {
|
||
t.Fatalf("banner = %+v", banner)
|
||
}
|
||
|
||
// 未来开始时间未到,不应出现在 active 列表
|
||
active, err := svc.ActiveBannerAnnouncements()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(active) != 0 {
|
||
t.Fatalf("future banner should not be active yet, got %d", len(active))
|
||
}
|
||
|
||
// 内链合法
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "内链", Link: "/projects", Status: "active"}); err != nil {
|
||
t.Fatalf("internal link should be accepted: %v", err)
|
||
}
|
||
// 非法链接拒绝
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "坏链接", Link: "javascript:alert(1)", Status: "active"}); err == nil {
|
||
t.Fatal("javascript: link should be rejected")
|
||
}
|
||
// 结束早于开始拒绝
|
||
start := time.Now().Add(-time.Hour)
|
||
end := time.Now().Add(-2 * time.Hour)
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "倒挂时间", Status: "active", StartsAt: &start, EndsAt: &end}); err == nil {
|
||
t.Fatal("endsAt before startsAt should be rejected")
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementValidityWindowFiltering(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
now := time.Now()
|
||
past := now.Add(-48 * time.Hour)
|
||
future := now.Add(48 * time.Hour)
|
||
|
||
// 已结束:start 在过去、end 已过 → 不展示
|
||
middle := past.Add(24 * time.Hour)
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "已过期", Status: "active", StartsAt: &past, EndsAt: &middle}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 生效中:start 已过、end 在未来 → 展示
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "生效中", Status: "active", StartsAt: &past, EndsAt: &future}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 未开始:start 在未来 → 不展示
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "未开始", Status: "active", StartsAt: &future}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 禁用:即使时间窗口内也不展示
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "被禁用", Status: "disabled"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
active, err := svc.ActiveBannerAnnouncements()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(active) != 1 || active[0].Title != "生效中" {
|
||
t.Fatalf("active = %+v, want only 生效中", active)
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementTitleRuns(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
size := 16
|
||
weight := 700
|
||
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
|
||
TitleRuns: []model.BannerTitleRun{
|
||
{Text: "限时", FontSize: &size, FontWeight: &weight, Color: "#ff0000"},
|
||
{Text: "活动", FontSize: &size, FontWeight: &weight, Color: "#FF0000"},
|
||
{Text: "已上线", FontFamily: "mono"},
|
||
},
|
||
Status: "active",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 相邻同样式分段合并,纯文本标题由分段拼接得出,供列表与检索使用。
|
||
if banner.Title != "限时活动已上线" {
|
||
t.Fatalf("title = %q, want 限时活动已上线", banner.Title)
|
||
}
|
||
if len(banner.TitleRuns) != 2 {
|
||
t.Fatalf("titleRuns = %+v, want 2 merged runs", banner.TitleRuns)
|
||
}
|
||
if banner.TitleRuns[0].Text != "限时活动" || banner.TitleRuns[0].Color != "#FF0000" {
|
||
t.Fatalf("first run = %+v", banner.TitleRuns[0])
|
||
}
|
||
|
||
// 读回时样式分段必须完整保留(列存储 + 显式编解码的回归点)。
|
||
stored, err := svc.repo.BannerAnnouncement(banner.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(stored.TitleRuns) != 2 || stored.TitleRuns[0].FontSize == nil || *stored.TitleRuns[0].FontSize != 16 {
|
||
t.Fatalf("stored = %+v", stored.TitleRuns)
|
||
}
|
||
active, err := svc.ActiveBannerAnnouncements()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(active) != 1 || len(active[0].TitleRuns) != 2 {
|
||
t.Fatalf("active = %+v", active)
|
||
}
|
||
|
||
// 更新走显式 map,样式分段同样不能被丢掉。
|
||
updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
|
||
TitleRuns: []model.BannerTitleRun{{Text: "更新后的标题", Color: "#00FF00"}},
|
||
Status: "active",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if updated.Title != "更新后的标题" || len(updated.TitleRuns) != 1 || updated.TitleRuns[0].Color != "#00FF00" {
|
||
t.Fatalf("updated = %+v", updated)
|
||
}
|
||
|
||
// 只传纯文本时清空样式分段。
|
||
plain, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{Title: "纯文本标题", Status: "active"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(plain.TitleRuns) != 0 || plain.Title != "纯文本标题" {
|
||
t.Fatalf("plain = %+v", plain)
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementRejectsInvalidTitleRuns(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
size := func(value int) *int { return &value }
|
||
weight := func(value int) *int { return &value }
|
||
|
||
cases := []struct {
|
||
name string
|
||
req CreateBannerAnnouncementRequest
|
||
}{
|
||
{name: "字号过小", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(9)}}, Status: "active"}},
|
||
{name: "字号过大", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(21)}}, Status: "active"}},
|
||
{name: "字重非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontWeight: weight(6000)}}, Status: "active"}},
|
||
{name: "字体非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontFamily: "comic-sans"}}, Status: "active"}},
|
||
{name: "颜色非法", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", Color: "red"}}, Status: "active"}},
|
||
{name: "颜色注入", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", Color: "#fff;background:url(x)"}}, Status: "active"}},
|
||
{name: "分段文本为空", req: CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "", FontSize: size(14)}}, Status: "active"}},
|
||
}
|
||
for _, testCase := range cases {
|
||
if _, err := svc.CreateBannerAnnouncement(admin, testCase.req); err == nil {
|
||
t.Fatalf("%s: expected rejection", testCase.name)
|
||
}
|
||
}
|
||
|
||
long := ""
|
||
for range 121 {
|
||
long += "字"
|
||
}
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: long}}, Status: "active"}); err == nil {
|
||
t.Fatal("over-long run text should be rejected")
|
||
}
|
||
// 边界值可接受:10 与 20、四种字重、三种字体。
|
||
for _, value := range []int{10, 20} {
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontSize: size(value)}}, Status: "active"}); err != nil {
|
||
t.Fatalf("font size %d should be accepted: %v", value, err)
|
||
}
|
||
}
|
||
for _, value := range []int{400, 500, 600, 700} {
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontWeight: weight(value)}}, Status: "active"}); err != nil {
|
||
t.Fatalf("font weight %d should be accepted: %v", value, err)
|
||
}
|
||
}
|
||
for _, value := range []string{"sans", "serif", "mono"} {
|
||
if _, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{TitleRuns: []model.BannerTitleRun{{Text: "标题", FontFamily: value}}, Status: "active"}); err != nil {
|
||
t.Fatalf("font family %q should be accepted: %v", value, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementNoticeType(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
// 未传类型时补成默认值,行数据始终是可直接渲染的具体取值。
|
||
def, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "默认外观", Status: "active"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if def.NoticeType != bannerNoticeDefaultType {
|
||
t.Fatalf("default noticeType = %q", def.NoticeType)
|
||
}
|
||
|
||
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
|
||
Title: "限时活动通知",
|
||
NoticeType: "activity",
|
||
Status: "active",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if banner.NoticeType != "activity" {
|
||
t.Fatalf("banner = %+v", banner)
|
||
}
|
||
|
||
// 读回路径(仓储 + 公开接口)都要保留类型。
|
||
stored, err := svc.repo.BannerAnnouncement(banner.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stored.NoticeType != "activity" {
|
||
t.Fatalf("stored = %+v", stored)
|
||
}
|
||
active, err := svc.ActiveBannerAnnouncements()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found := false
|
||
for _, item := range active {
|
||
if item.ID == banner.ID && item.NoticeType == "activity" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("active = %+v, want activity", active)
|
||
}
|
||
|
||
// 更新走显式字段表,漏字段会被静默清空的回归点。
|
||
updated, err := svc.UpdateBannerAnnouncement(admin, banner.ID, UpdateBannerAnnouncementRequest{
|
||
Title: "改成警告",
|
||
NoticeType: "warning",
|
||
Status: "active",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if updated.NoticeType != "warning" {
|
||
t.Fatalf("updated = %+v", updated)
|
||
}
|
||
reloaded, err := svc.repo.BannerAnnouncement(banner.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if reloaded.NoticeType != "warning" {
|
||
t.Fatalf("reloaded = %+v", reloaded)
|
||
}
|
||
|
||
// 边界:全部合法取值都必须被接受,否则后台选了却被静默丢弃。
|
||
for value := range bannerNoticeTypes {
|
||
created, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "类型 " + value, NoticeType: value, Status: "active"})
|
||
if err != nil {
|
||
t.Fatalf("notice type %q should be accepted: %v", value, err)
|
||
}
|
||
if created.NoticeType != value {
|
||
t.Fatalf("notice type %q normalized to %q", value, created.NoticeType)
|
||
}
|
||
}
|
||
|
||
// 大小写与空格归一化。
|
||
normalized, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "归一化", NoticeType: " ACTIVITY ", Status: "active"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if normalized.NoticeType != "activity" {
|
||
t.Fatalf("normalized = %+v", normalized)
|
||
}
|
||
}
|
||
|
||
func TestBannerAnnouncementRejectsUnknownNoticeType(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
cases := []struct {
|
||
name string
|
||
req CreateBannerAnnouncementRequest
|
||
}{
|
||
{name: "类型不在白名单", req: CreateBannerAnnouncementRequest{Title: "标题", NoticeType: "danger", Status: "active"}},
|
||
{name: "类型注入", req: CreateBannerAnnouncementRequest{Title: "标题", NoticeType: "notice;--x:1", Status: "active"}},
|
||
}
|
||
for _, testCase := range cases {
|
||
if _, err := svc.CreateBannerAnnouncement(admin, testCase.req); err == nil {
|
||
t.Fatalf("%s: expected rejection", testCase.name)
|
||
}
|
||
}
|
||
|
||
// 更新路径同样受白名单约束,不能绕过校验改坏已存在的行。
|
||
created, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{Title: "已有通知", Status: "active"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := svc.UpdateBannerAnnouncement(admin, created.ID, UpdateBannerAnnouncementRequest{Title: "改坏", NoticeType: "rainbow", Status: "active"}); err == nil {
|
||
t.Fatal("update with unknown notice type should be rejected")
|
||
}
|
||
}
|
||
|
||
// emoji 图标素材就是普通文本分段:不需要独立字段或白名单,但必须能完整往返、并入纯文本标题参与检索。
|
||
func TestBannerAnnouncementTitleRunsWithEmoji(t *testing.T) {
|
||
svc, _ := newAnnouncementImageTestService(t)
|
||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||
|
||
banner, err := svc.CreateBannerAnnouncement(admin, CreateBannerAnnouncementRequest{
|
||
TitleRuns: []model.BannerTitleRun{
|
||
{Text: "💰"},
|
||
{Text: "双 11 预售开启,全场"},
|
||
{Text: "5 折", Color: "#FFE58F", FontWeight: func() *int { v := 700; return &v }()},
|
||
{Text: "🚀"},
|
||
},
|
||
NoticeType: "activity",
|
||
Status: "active",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if banner.Title != "💰双 11 预售开启,全场5 折🚀" {
|
||
t.Fatalf("title = %q", banner.Title)
|
||
}
|
||
if len(banner.TitleRuns) != 3 {
|
||
t.Fatalf("titleRuns = %+v, want 3(开头 emoji 与正文同为默认样式应合并)", banner.TitleRuns)
|
||
}
|
||
|
||
stored, err := svc.repo.BannerAnnouncement(banner.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(stored.TitleRuns) != 3 {
|
||
t.Fatalf("stored = %+v", stored.TitleRuns)
|
||
}
|
||
active, err := svc.ActiveBannerAnnouncements()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(active) != 1 || active[0].Title != banner.Title {
|
||
t.Fatalf("active = %+v", active)
|
||
}
|
||
}
|
||
|
||
func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) {
|
||
t.Helper()
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB.SetMaxOpenConns(1)
|
||
if err := db.AutoMigrate(database.Models()...); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return New(repository.New(db), t.TempDir()), db
|
||
}
|
||
|
||
func createAnnouncementImageDraft(t *testing.T, db *gorm.DB, userID string, id string) *model.Resource {
|
||
t.Helper()
|
||
resource := &model.Resource{
|
||
ID: id, UserID: userID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local",
|
||
ObjectKey: "users/" + userID + "/image/" + id + ".png", MimeType: "image/png", Size: 128,
|
||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||
}
|
||
if err := db.Create(resource).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Create(&model.AnnouncementImageDraft{ResourceID: resource.ID, UserID: userID, CreatedAt: time.Now()}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return resource
|
||
}
|