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

310 lines
11 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 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
}