- 新增 mailboxes 表与 MailboxStore(系统文件夹幂等创建,自定义文件夹 CRUD) - 提取 MailboxService:IMAP 会话与 Web handler 共用,LIST 返回什么 Web 就显示什么 - IMAP 支持 CREATE/DELETE/RENAME/SUBSCRIBE(系统文件夹禁删改、非空禁删) - Web 删除改为 IMAP 语义:移入 Trash,新增恢复/彻底删除/清空 - 新增通用 /folder/:name 页面与动态侧边栏,/inbox /sent /drafts 保留兼容重定向
189 lines
5.4 KiB
Go
189 lines
5.4 KiB
Go
package handlers
|
||
|
||
// Web 删除语义回归测试:删除=移入垃圾箱(IMAP 语义)、垃圾箱中删除=彻底
|
||
// 删除、恢复=回到收件箱、清空=永久删除。文件夹操作全部经由 MailboxService
|
||
// (与 IMAP 会话共用同一份实现)。
|
||
|
||
import (
|
||
"html/template"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"mail_go/internal/db"
|
||
"mail_go/internal/imap_server"
|
||
"mail_go/internal/store"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func newMailTestHandler(t *testing.T) (*MailHandler, *store.Stores) {
|
||
t.Helper()
|
||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stores := store.NewStores(gdb)
|
||
if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return NewMailHandler(stores, nil, nil, imap_server.NewMailboxService(stores), nil), stores
|
||
}
|
||
|
||
// newMailTestRouter 注册删除/恢复/清空路由并注入认证上下文。
|
||
func newMailTestRouter(h *MailHandler) *gin.Engine {
|
||
gin.SetMode(gin.TestMode)
|
||
r := gin.New()
|
||
// Folder 页渲染需要模板(与 oauth2 测试共用同一份测试函数表)
|
||
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
||
r.SetHTMLTemplate(tmpl)
|
||
r.Use(func(c *gin.Context) {
|
||
c.Set("userID", uint(1))
|
||
c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}})
|
||
c.Next()
|
||
})
|
||
r.POST("/mail/delete/:id", h.Delete)
|
||
r.POST("/mail/restore/:id", h.Restore)
|
||
r.POST("/mail/purge/:id", h.Purge)
|
||
r.POST("/folder/:name/empty", h.EmptyFolder)
|
||
r.GET("/folder/:name", h.Folder)
|
||
return r
|
||
}
|
||
|
||
func seedWebMsg(t *testing.T, stores *store.Stores, folder string) *db.Message {
|
||
t.Helper()
|
||
msg := &db.Message{
|
||
UserID: 1,
|
||
Folder: folder,
|
||
FromAddr: "sender@other.com",
|
||
ToAddr: "alice@example.com",
|
||
Subject: "测试邮件",
|
||
Date: time.Now(),
|
||
}
|
||
if err := stores.Mails.Create(msg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return msg
|
||
}
|
||
|
||
func msgFolder(t *testing.T, stores *store.Stores, id uint) (string, bool) {
|
||
t.Helper()
|
||
msg, err := stores.Mails.GetByID(id)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return msg.Folder, true
|
||
}
|
||
|
||
func TestWebDeleteMovesToTrash(t *testing.T) {
|
||
h, stores := newMailTestHandler(t)
|
||
msg := seedWebMsg(t, stores, "INBOX")
|
||
r := newMailTestRouter(h)
|
||
|
||
// 收件箱删除 → 移入垃圾箱(非物理删除)
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
|
||
req.Header.Set("Referer", "/folder/INBOX")
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusFound {
|
||
t.Fatalf("delete status = %d, want 302", w.Code)
|
||
}
|
||
folder, ok := msgFolder(t, stores, msg.ID)
|
||
if !ok || folder != "Trash" {
|
||
t.Fatalf("deleted msg folder = %q, want Trash", folder)
|
||
}
|
||
|
||
// 垃圾箱中再删除 → 彻底删除
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
|
||
req2.Header.Set("Referer", "/folder/Trash")
|
||
r.ServeHTTP(w2, req2)
|
||
if _, ok := msgFolder(t, stores, msg.ID); ok {
|
||
t.Fatal("trash delete should purge the message")
|
||
}
|
||
}
|
||
|
||
func TestWebRestoreToInbox(t *testing.T) {
|
||
h, stores := newMailTestHandler(t)
|
||
msg := seedWebMsg(t, stores, "Trash")
|
||
r := newMailTestRouter(h)
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/mail/restore/"+itoa(msg.ID), nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusFound {
|
||
t.Fatalf("restore status = %d, want 302", w.Code)
|
||
}
|
||
folder, ok := msgFolder(t, stores, msg.ID)
|
||
if !ok || folder != "INBOX" {
|
||
t.Fatalf("restored msg folder = %q, want INBOX", folder)
|
||
}
|
||
}
|
||
|
||
func TestWebEmptyFolderPurgesAll(t *testing.T) {
|
||
h, stores := newMailTestHandler(t)
|
||
seedWebMsg(t, stores, "Trash")
|
||
seedWebMsg(t, stores, "Trash")
|
||
r := newMailTestRouter(h)
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/folder/Trash/empty", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusFound {
|
||
t.Fatalf("empty status = %d, want 302", w.Code)
|
||
}
|
||
count, err := stores.Mails.CountByUserAndFolder(1, "Trash")
|
||
if err != nil || count != 0 {
|
||
t.Fatalf("Trash count after empty = %d, want 0", count)
|
||
}
|
||
}
|
||
|
||
func TestWebFolderPageListsDynamicFolders(t *testing.T) {
|
||
h, stores := newMailTestHandler(t)
|
||
seedWebMsg(t, stores, "INBOX")
|
||
r := newMailTestRouter(h)
|
||
|
||
// IMAP CREATE 语义创建的自定义文件夹(经同一 MailboxService)
|
||
svc := imap_server.NewMailboxService(stores)
|
||
if err := svc.Create(1, "工作"); err != nil {
|
||
t.Fatalf("create custom mailbox: %v", err)
|
||
}
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodGet, "/folder/工作", nil)
|
||
r.ServeHTTP(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("custom folder page status = %d, want 200", w.Code)
|
||
}
|
||
|
||
// 不存在的文件夹 → 404
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest(http.MethodGet, "/folder/不存在", nil)
|
||
r.ServeHTTP(w2, req2)
|
||
if w2.Code != http.StatusNotFound {
|
||
t.Fatalf("missing folder status = %d, want 404", w2.Code)
|
||
}
|
||
}
|
||
|
||
func itoa(n uint) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
var b []byte
|
||
for n > 0 {
|
||
b = append([]byte{byte('0' + n%10)}, b...)
|
||
n /= 10
|
||
}
|
||
return string(b)
|
||
}
|