Files
ttstd-infinite-canvas/backend/internal/app/announcement_test.go

701 lines
26 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 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
}