From d36d57692ceffed63b4d2b36b275f51d50596723 Mon Sep 17 00:00:00 2001 From: zhr Date: Mon, 21 Sep 2026 15:42:16 +0800 Subject: [PATCH] feat: test case --- Makefile | 20 ++ internal/module/admin/logic/jwt_test.go | 17 + internal/module/admin/logic/map.go | 2 +- internal/module/admin/logic/map_test.go | 63 ++++ internal/module/admin/logic/service.go | 33 +- internal/module/admin/logic/service_test.go | 322 ++++++++++++++++++ internal/module/auth/logic/jwt_test.go | 24 ++ internal/module/auth/logic/login_test.go | 59 ++++ .../module/conversation/logic/p2p_test.go | 106 ++++++ .../module/favorite/logic/favorite_test.go | 65 ++++ internal/module/inbox/logic/store_test.go | 29 ++ internal/module/message/logic/message_test.go | 21 ++ internal/module/moment/logic/moment_test.go | 51 +++ internal/module/session/logic/policy_test.go | 7 + 14 files changed, 816 insertions(+), 3 deletions(-) create mode 100644 internal/module/admin/logic/map_test.go create mode 100644 internal/module/admin/logic/service_test.go create mode 100644 internal/module/conversation/logic/p2p_test.go create mode 100644 internal/module/inbox/logic/store_test.go create mode 100644 internal/module/session/logic/policy_test.go diff --git a/Makefile b/Makefile index b4bd077..2e88c6a 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ MODULE := spark-server BIN := bin/spark-server LINT ?= golangci-lint CONFIG ?= configs/config.yaml +LOGIC_COVER_MIN ?= 65 .PHONY: all build test lint fmt vet tidy migrate start run deploy @@ -11,8 +12,27 @@ all: lint test build build: $(GO) build -o $(BIN) ./cmd/server +# All unit tests, then each module logic package must be >= LOGIC_COVER_MIN. test: $(GO) test ./... + @echo "==> logic Service coverage (>= $(LOGIC_COVER_MIN)%)" + @covout=$$($(GO) test -cover ./internal/module/*/logic); \ + status=$$?; \ + printf '%s\n' "$$covout"; \ + if [ $$status -ne 0 ]; then exit $$status; fi; \ + printf '%s\n' "$$covout" | awk -v min=$(LOGIC_COVER_MIN) ' \ + BEGIN { bad=0 } \ + /\[no test files\]/ { printf "FAIL %s: no test files\n", $$2; bad=1; next } \ + { \ + cov=""; \ + for (i = 1; i <= NF; i++) { \ + if ($$i == "coverage:") { cov=$$(i+1); break } \ + } \ + if (cov == "" || cov == "[no") next; \ + gsub("%", "", cov); \ + if (cov+0 < min+0) { printf "FAIL %s: coverage %s%% < %s%%\n", $$2, cov, min; bad=1 } \ + } \ + END { if (bad) exit 1 }' vet: $(GO) vet ./... diff --git a/internal/module/admin/logic/jwt_test.go b/internal/module/admin/logic/jwt_test.go index d417242..dfcc3b7 100644 --- a/internal/module/admin/logic/jwt_test.go +++ b/internal/module/admin/logic/jwt_test.go @@ -29,3 +29,20 @@ func TestAdminJWTRejectsWrongType(t *testing.T) { t.Fatal("expected invalid") } } + +func TestAdminJWTShortSecretAndNil(t *testing.T) { + s := NewTokenService("short") + tok, err := s.CreateToken(1, "ops", "SUPER_ADMIN", "") + if err != nil { + t.Fatal(err) + } + if _, err := s.Parse(tok); err != nil { + t.Fatal(err) + } + if _, err := (*TokenService)(nil).Parse("x"); err == nil { + t.Fatal("nil parse") + } + if _, err := s.Parse(""); err == nil { + t.Fatal("empty") + } +} diff --git a/internal/module/admin/logic/map.go b/internal/module/admin/logic/map.go index c84f5f0..832f51b 100644 --- a/internal/module/admin/logic/map.go +++ b/internal/module/admin/logic/map.go @@ -121,7 +121,7 @@ func userView(r adminmodel.UserRow) adminentity.UserView { v := adminentity.UserView{ ID: r.ID, Email: r.Email, Nickname: r.Nickname, SparkID: r.SparkID, AvatarURL: r.Avatar, Channel: r.Platform, Status: r.Status, - Banned: r.Status == int(userentity.StatusDisabled), + Banned: r.Status == int(userentity.StatusDisabled), CreatedAt: r.CreatedAt.Format(time.RFC3339), } if r.DeviceID > 0 { diff --git a/internal/module/admin/logic/map_test.go b/internal/module/admin/logic/map_test.go new file mode 100644 index 0000000..0da1a24 --- /dev/null +++ b/internal/module/admin/logic/map_test.go @@ -0,0 +1,63 @@ +package logic + +import ( + "testing" + "time" + + adminmodel "spark-server/internal/module/admin/model" + msgentity "spark-server/internal/module/message/entity" +) + +func TestAdminMessageHelpers(t *testing.T) { + if msgTypeName(msgentity.TypeText) != "text" || msgTypeName(msgentity.Type(99)) != "type_99" { + t.Fatal("type name") + } + if roleName(1) != "owner" || roleName(2) != "admin" || roleName(3) != "member" { + t.Fatal("role") + } + if reportReasonName(1) != "spam" || reportReasonName(2) != "harassment" || reportReasonName(3) != "fake" || reportReasonName(0) != "other" { + t.Fatal("reason name") + } + if parseReportReason("spam") != 1 || parseReportReason("骚扰") != 2 || parseReportReason("3") != 3 || parseReportReason("x") != 4 { + t.Fatal("parse reason") + } + mustPreview(t, msgentity.NewText("hi"), msgentity.TypeText, "hi") + mustPreview(t, msgentity.NewImage(&msgentity.Image{Key: "chat/20260917/1/99.jpg", Mime: "image/jpeg"}), msgentity.TypeImage, "[图片]") + mustPreview(t, msgentity.NewVoice(&msgentity.Voice{Key: "chat/20260917/1/a.m4a", DurationMS: 1000}), msgentity.TypeVoice, "[语音]") + mustPreview(t, msgentity.NewVideo(&msgentity.Video{Key: "chat/20260917/1/a.mp4"}), msgentity.TypeVideo, "[视频]") + mustPreview(t, msgentity.NewEmoji(&msgentity.Emoji{Pack: "default", ID: "smile"}), msgentity.TypeEmoji, "[表情]") + mustPreview(t, msgentity.NewGIF(&msgentity.GIF{Key: "chat/20260917/1/a.gif"}), msgentity.TypeGIF, "[GIF]") + mustPreview(t, msgentity.NewFile(&msgentity.File{Key: "chat/20260917/1/a.pdf", Name: "a.pdf", Size: 1}), msgentity.TypeFile, "[文件] a.pdf") + mustPreview(t, msgentity.NewLocation(&msgentity.Location{Lat: 1, Lng: 2, Name: "cafe"}), msgentity.TypeLocation, "[位置] cafe") + mustPreview(t, msgentity.NewSystem(&msgentity.System{Kind: "join", Body: "joined"}), msgentity.TypeSystem, "joined") + if previewText([]byte("not-json"), msgentity.TypeText) != "" { + t.Fatal("bad content") + } + v := messageView(adminmodel.MessageRow{ + ID: 42, SenderID: 7, ReceiverID: 8, GroupID: 9, MessageType: int16(msgentity.TypeText), + Content: mustMarshal(t, msgentity.NewText("needle")), Status: 2, CreatedAt: time.Unix(1, 0).UTC(), ChatType: "private", + }) + if !v.Revoked || !messageMatch(v, "needle") || !messageMatch(v, "42") || !messageMatch(v, "text") || !messageMatch(v, "7") { + t.Fatalf("%+v", v) + } + if messageMatch(v, "zzz") { + t.Fatal("no match") + } +} + +func mustMarshal(t *testing.T, c *msgentity.Content) []byte { + t.Helper() + raw, err := c.Marshal() + if err != nil { + t.Fatal(err) + } + return raw +} + +func mustPreview(t *testing.T, c *msgentity.Content, typ msgentity.Type, want string) { + t.Helper() + got := previewText(mustMarshal(t, c), typ) + if got != want { + t.Fatalf("preview %q want %q", got, want) + } +} diff --git a/internal/module/admin/logic/service.go b/internal/module/admin/logic/service.go index 82f4453..aaf0737 100644 --- a/internal/module/admin/logic/service.go +++ b/internal/module/admin/logic/service.go @@ -2,17 +2,21 @@ package logic import ( + "context" "errors" + adminentity "spark-server/internal/module/admin/entity" adminmodel "spark-server/internal/module/admin/model" + friendentity "spark-server/internal/module/friend/entity" grouplogic "spark-server/internal/module/group/logic" + userentity "spark-server/internal/module/user/entity" ) var ErrNotReady = errors.New("admin: repository not ready") // Service orchestrates admin use-cases. SQL lives in model.Repo. type Service struct { - repo *adminmodel.Repo + repo Repository groups *grouplogic.Service ids IDGen } @@ -21,7 +25,32 @@ type IDGen interface { NextID() (int64, error) } -func NewService(repo *adminmodel.Repo, groups *grouplogic.Service, ids IDGen) *Service { +// Repository is admin read/write over V3 tables. Model implements it. +type Repository interface { + ListUsers(ctx context.Context, emailLike string, page, size int) ([]adminmodel.UserRow, int64, error) + UsersByEmail(ctx context.Context, email string) ([]adminmodel.UserRow, error) + UserByID(ctx context.Context, id int64) (*adminmodel.UserRow, error) + UsersByNickname(ctx context.Context, nickname string) ([]adminmodel.UserRow, error) + UsersByDevice(ctx context.Context, deviceID string, deviceIDNum uint64) ([]adminmodel.UserRow, error) + UsersByIP(ctx context.Context, ip string) ([]adminmodel.UserRow, error) + SetUserStatusByEmail(ctx context.Context, email string, status userentity.Status) error + SetNicknameByEmail(ctx context.Context, email, nickname string) error + ClearAvatarByEmail(ctx context.Context, email string) error + SameIPRank(ctx context.Context, limit int) ([]adminmodel.SameIPRankRow, error) + LookupUserIDByEmail(ctx context.Context, email string) (int64, error) + ListGroups(ctx context.Context, q string) ([]adminmodel.GroupRow, error) + GroupByID(ctx context.Context, groupID int64) (*adminmodel.GroupRow, error) + ActiveGroupOwner(ctx context.Context, groupID int64) (int64, error) + ListGroupMembers(ctx context.Context, groupID int64) ([]adminmodel.GroupMemberRow, error) + ListGroupMessages(ctx context.Context, groupID int64, limit int) ([]adminmodel.MessageRow, error) + ListComplaints(ctx context.Context, limit int) ([]adminmodel.ComplaintRow, error) + InsertComplaint(ctx context.Context, r *friendentity.Report) error + ImStats(ctx context.Context) (*adminentity.ImStats, error) + ListRecentMessages(ctx context.Context, fetchLimit int) ([]adminmodel.MessageRow, error) + ListUserConversations(ctx context.Context, userID int64) ([]adminmodel.ConversationRow, error) +} + +func NewService(repo Repository, groups *grouplogic.Service, ids IDGen) *Service { return &Service{repo: repo, groups: groups, ids: ids} } diff --git a/internal/module/admin/logic/service_test.go b/internal/module/admin/logic/service_test.go new file mode 100644 index 0000000..7b7db33 --- /dev/null +++ b/internal/module/admin/logic/service_test.go @@ -0,0 +1,322 @@ +package logic + +import ( + "context" + "errors" + "testing" + "time" + + adminentity "spark-server/internal/module/admin/entity" + adminmodel "spark-server/internal/module/admin/model" + friendentity "spark-server/internal/module/friend/entity" + groupentity "spark-server/internal/module/group/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +type seqID struct{ n int64 } + +func (s *seqID) NextID() (int64, error) { + s.n++ + return s.n, nil +} + +type memoryAdmin struct { + users []adminmodel.UserRow + emails map[string]int64 + groups map[int64]adminmodel.GroupRow + members map[int64][]adminmodel.GroupMemberRow + groupMsgs map[int64][]adminmodel.MessageRow + msgs []adminmodel.MessageRow + convs []adminmodel.ConversationRow + complaints []adminmodel.ComplaintRow + stats *adminentity.ImStats + ips []adminmodel.SameIPRankRow + owners map[int64]int64 +} + +func (m *memoryAdmin) ListUsers(_ context.Context, emailLike string, page, size int) ([]adminmodel.UserRow, int64, error) { + start := page * size + if start >= len(m.users) { + return nil, int64(len(m.users)), nil + } + end := start + size + if end > len(m.users) { + end = len(m.users) + } + _ = emailLike + return m.users[start:end], int64(len(m.users)), nil +} + +func (m *memoryAdmin) UsersByEmail(_ context.Context, email string) ([]adminmodel.UserRow, error) { + out := make([]adminmodel.UserRow, 0) + for _, u := range m.users { + if u.Email == email { + out = append(out, u) + } + } + return out, nil +} + +func (m *memoryAdmin) UserByID(_ context.Context, id int64) (*adminmodel.UserRow, error) { + for i := range m.users { + if m.users[i].ID == id { + cp := m.users[i] + return &cp, nil + } + } + return nil, errs.ErrNotFound +} + +func (m *memoryAdmin) UsersByNickname(_ context.Context, nickname string) ([]adminmodel.UserRow, error) { + out := make([]adminmodel.UserRow, 0) + for _, u := range m.users { + if u.Nickname == nickname { + out = append(out, u) + } + } + return out, nil +} + +func (m *memoryAdmin) UsersByDevice(_ context.Context, _ string, deviceIDNum uint64) ([]adminmodel.UserRow, error) { + out := make([]adminmodel.UserRow, 0) + for _, u := range m.users { + if u.DeviceID == deviceIDNum { + out = append(out, u) + } + } + return out, nil +} + +func (m *memoryAdmin) UsersByIP(_ context.Context, _ string) ([]adminmodel.UserRow, error) { + return m.users, nil +} + +func (m *memoryAdmin) SetUserStatusByEmail(_ context.Context, email string, status userentity.Status) error { + for i := range m.users { + if m.users[i].Email == email { + m.users[i].Status = int(status) + return nil + } + } + return errs.ErrNotFound +} + +func (m *memoryAdmin) SetNicknameByEmail(_ context.Context, email, nickname string) error { + for i := range m.users { + if m.users[i].Email == email { + m.users[i].Nickname = nickname + return nil + } + } + return errs.ErrNotFound +} + +func (m *memoryAdmin) ClearAvatarByEmail(_ context.Context, email string) error { + for i := range m.users { + if m.users[i].Email == email { + m.users[i].Avatar = "" + return nil + } + } + return errs.ErrNotFound +} + +func (m *memoryAdmin) SameIPRank(_ context.Context, _ int) ([]adminmodel.SameIPRankRow, error) { + return m.ips, nil +} + +func (m *memoryAdmin) LookupUserIDByEmail(_ context.Context, email string) (int64, error) { + if m.emails == nil { + return 0, nil + } + return m.emails[email], nil +} + +func (m *memoryAdmin) ListGroups(_ context.Context, _ string) ([]adminmodel.GroupRow, error) { + out := make([]adminmodel.GroupRow, 0, len(m.groups)) + for id := range m.groups { + out = append(out, m.groups[id]) + } + return out, nil +} + +func (m *memoryAdmin) GroupByID(_ context.Context, groupID int64) (*adminmodel.GroupRow, error) { + g, ok := m.groups[groupID] + if !ok { + return nil, errs.ErrNotFound + } + return &g, nil +} + +func (m *memoryAdmin) ActiveGroupOwner(_ context.Context, groupID int64) (int64, error) { + id, ok := m.owners[groupID] + if !ok { + return 0, errs.ErrNotFound + } + return id, nil +} + +func (m *memoryAdmin) ListGroupMembers(_ context.Context, groupID int64) ([]adminmodel.GroupMemberRow, error) { + return m.members[groupID], nil +} + +func (m *memoryAdmin) ListGroupMessages(_ context.Context, groupID int64, _ int) ([]adminmodel.MessageRow, error) { + return m.groupMsgs[groupID], nil +} + +func (m *memoryAdmin) ListComplaints(_ context.Context, _ int) ([]adminmodel.ComplaintRow, error) { + return m.complaints, nil +} + +func (m *memoryAdmin) InsertComplaint(_ context.Context, r *friendentity.Report) error { + if r == nil { + return errs.ErrInvalidArgument + } + m.complaints = append(m.complaints, adminmodel.ComplaintRow{ + ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, Reason: int(r.Reason), Detail: r.Detail, CreatedAt: r.CreatedAt, + }) + return nil +} + +func (m *memoryAdmin) ImStats(context.Context) (*adminentity.ImStats, error) { + return m.stats, nil +} + +func (m *memoryAdmin) ListRecentMessages(_ context.Context, _ int) ([]adminmodel.MessageRow, error) { + return m.msgs, nil +} + +func (m *memoryAdmin) ListUserConversations(_ context.Context, _ int64) ([]adminmodel.ConversationRow, error) { + return m.convs, nil +} + +func TestAdminServiceNotReady(t *testing.T) { + s := &Service{} + if _, _, err := s.ListUsers(t.Context(), "", 0, 0); !errors.Is(err, ErrNotReady) { + t.Fatalf("list %v", err) + } +} + +func TestAdminServiceOps(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + active := now + repo := &memoryAdmin{ + users: []adminmodel.UserRow{{ + ID: 11, Email: "a@spark.com", Nickname: "Ada", SparkID: "ada_ok01", Status: 1, + DeviceID: 7, Platform: "ios", CreatedAt: now, LastActiveAt: &active, + }}, + emails: map[string]int64{"a@spark.com": 11, "b@spark.com": 12}, + groups: map[int64]adminmodel.GroupRow{ + 3: {ID: 3, Name: "g", OwnerID: 11, Status: int(groupentity.StatusActive), MemberCount: 2, CreatedAt: now, UpdatedAt: now}, + }, + members: map[int64][]adminmodel.GroupMemberRow{ + 3: {{UserID: 11, Role: int(groupentity.RoleOwner), Nickname: "", UNick: "Ada", Email: "a@spark.com"}}, + }, + groupMsgs: map[int64][]adminmodel.MessageRow{ + 3: {{ID: 99, SenderID: 11, MessageType: int16(msgentity.TypeText), CreatedAt: now}}, + }, + msgs: []adminmodel.MessageRow{ + {ID: 1, SenderID: 11, ReceiverID: 12, MessageType: int16(msgentity.TypeText), Content: []byte(`{"t":1,"text":{"body":"hi"}}`), CreatedAt: now, ChatType: "private"}, + }, + convs: []adminmodel.ConversationRow{ + {ID: 4, Type: 1, UserSmall: 11, UserBig: 12, LastMsgID: 1, LastMsgAt: now}, + {ID: 5, Type: 2, GroupID: 3, LastMsgID: 99, LastMsgAt: now}, + }, + complaints: []adminmodel.ComplaintRow{{ID: 1, Email: "a@spark.com", TargetID: 12, Reason: 1, CreatedAt: now}}, + stats: &adminentity.ImStats{MessageCount: 2}, + ips: []adminmodel.SameIPRankRow{{IP: "1.1.1.1", UserCount: 2, DeviceCount: 3}}, + owners: map[int64]int64{3: 11}, + } + svc := NewService(repo, nil, &seqID{n: 100}) + ctx := t.Context() + + list, total, err := svc.ListUsers(ctx, "", -1, 0) + if err != nil || total != 1 || len(list) != 1 || list[0].Email != "a@spark.com" { + t.Fatalf("list %+v %d %v", list, total, err) + } + byMail, err := svc.UserByEmail(ctx, "A@spark.com") + if err != nil || byMail["email"] != "a@spark.com" { + t.Fatalf("email %+v %v", byMail, err) + } + if _, err := svc.UserByEmail(ctx, " "); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("empty email %v", err) + } + u, err := svc.UserByID(ctx, 11) + if err != nil || u.ID != 11 || u.DeviceID != "7" { + t.Fatalf("by id %+v %v", u, err) + } + nicks, err := svc.UsersByNickname(ctx, "Ada") + if err != nil || len(nicks) != 1 { + t.Fatalf("nick %+v %v", nicks, err) + } + devs, err := svc.UsersByDevice(ctx, "7") + if err != nil || len(devs) != 1 { + t.Fatalf("dev %+v %v", devs, err) + } + ips, err := svc.UsersByIP(ctx, "1.1.1.1") + if err != nil || len(ips) != 1 { + t.Fatalf("ip %+v %v", ips, err) + } + if err := svc.BanByEmail(ctx, "a@spark.com", true); err != nil { + t.Fatal(err) + } + if repo.users[0].Status != int(userentity.StatusDisabled) { + t.Fatal("ban") + } + if err := svc.ResetNicknameByEmail(ctx, "a@spark.com", ""); err != nil || repo.users[0].Nickname != "用户" { + t.Fatalf("nick %v %s", err, repo.users[0].Nickname) + } + if err := svc.ResetAvatarByEmail(ctx, "a@spark.com"); err != nil || repo.users[0].Avatar != "" { + t.Fatal(err) + } + rank, err := svc.SameIPRank(ctx, 0) + if err != nil || len(rank) != 1 || rank[0].Count != 2 { + t.Fatalf("rank %+v %v", rank, err) + } + + groups, err := svc.ListGroups(ctx, "") + if err != nil || len(groups) != 1 || groups[0].GroupID != "3" { + t.Fatalf("groups %+v %v", groups, err) + } + detail, err := svc.GroupDetail(ctx, "3") + if err != nil || detail == nil || len(detail.Members) != 1 || detail.Members[0].Role != "owner" { + t.Fatalf("detail %+v %v", detail, err) + } + if _, err := svc.GroupDetail(ctx, "x"); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("bad gid %v", err) + } + if err := svc.DissolveGroup(ctx, "3"); !errors.Is(err, ErrNotReady) { + t.Fatalf("dissolve %v", err) + } + + stats, err := svc.ImStats(ctx) + if err != nil || stats.MessageCount != 2 { + t.Fatalf("stats %+v %v", stats, err) + } + msgs, err := svc.ListMessages(ctx, "hi", 0) + if err != nil || len(msgs) != 1 { + t.Fatalf("msgs %+v %v", msgs, err) + } + convs, gviews, st, err := svc.ListConversations(ctx, 11) + if err != nil || st != nil || len(convs) != 2 || len(gviews) != 1 { + t.Fatalf("convs %+v %+v %v", convs, gviews, err) + } + _, _, st, err = svc.ListConversations(ctx, 0) + if err != nil || st == nil { + t.Fatalf("global stats %v", err) + } + + cs, err := svc.ListComplaints(ctx) + if err != nil || len(cs) != 1 || cs[0].Target != "12" { + t.Fatalf("complaints %+v %v", cs, err) + } + added, err := svc.AddComplaint(ctx, "a@spark.com", "b@spark.com", "spam", "ads") + if err != nil || added == nil || added.Reason != "spam" { + t.Fatalf("add %+v %v", added, err) + } + if _, err := svc.AddComplaint(ctx, "", "12", "other", ""); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("bad complaint %v", err) + } +} diff --git a/internal/module/auth/logic/jwt_test.go b/internal/module/auth/logic/jwt_test.go index ff8d14d..46c9b30 100644 --- a/internal/module/auth/logic/jwt_test.go +++ b/internal/module/auth/logic/jwt_test.go @@ -232,3 +232,27 @@ func TestMemoryDenylistExpiry(t *testing.T) { t.Fatalf("expired still present: %v %v", ok, err) } } + +func TestRedisStoresRequireClient(t *testing.T) { + ctx := t.Context() + d := NewRedisDenylist(nil, "") + if _, err := d.Has(ctx, "j"); err == nil { + t.Fatal("denylist has") + } + if err := d.Add(ctx, "j", time.Second); err == nil { + t.Fatal("denylist add") + } + p := NewRedisPairs(nil, "") + if err := p.Put(ctx, 1, 1, TokenPair{Access: "a"}, time.Second); err == nil { + t.Fatal("pairs put") + } + if _, err := p.Get(ctx, 1, 1); err == nil { + t.Fatal("pairs get") + } + if err := p.Delete(ctx, 1, 1); err == nil { + t.Fatal("pairs del") + } + if _, err := p.ListByUser(ctx, 1); err == nil { + t.Fatal("pairs list") + } +} diff --git a/internal/module/auth/logic/login_test.go b/internal/module/auth/logic/login_test.go index 7bfe8f0..4bae058 100644 --- a/internal/module/auth/logic/login_test.go +++ b/internal/module/auth/logic/login_test.go @@ -41,6 +41,65 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) { } } +func TestLoginServiceSendRefreshLogout(t *testing.T) { + j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + users := StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"} + kicker := &kickRec{} + svc := NewLoginService(j, users) + svc.Sessions = kicker + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"}) + sent, err := svc.SendEmailCode(ctx, SendCodeInput{Email: "test01@spark.com"}) + if err != nil || sent == nil || sent.ExpiresIn != userlogic.EmailCodeTTLSeconds { + t.Fatalf("code %+v %v", sent, err) + } + tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"}) + if err != nil { + t.Fatal(err) + } + refreshed, err := svc.Refresh(ctx, RefreshInput{RefreshToken: tok.RefreshToken}) + if err != nil || refreshed.UserID != 11 { + t.Fatalf("refresh %+v %v", refreshed, err) + } + p, err := j.Authenticate(ctx, refreshed.AccessToken) + if err != nil { + t.Fatal(err) + } + outCtx := reqctx.With(ctx, &reqctx.RequestContext{UserID: p.UserID, DeviceID: p.DeviceID, JTI: p.JTI, TokenExpires: p.ExpiresAt}) + if err := svc.Logout(outCtx); err != nil { + t.Fatal(err) + } + if kicker.n != 1 { + t.Fatalf("kick %d", kicker.n) + } + if _, err := svc.SendEmailCode(ctx, SendCodeInput{}); err == nil { + t.Fatal("empty email") + } + if _, err := (*LoginService)(nil).Refresh(ctx, RefreshInput{RefreshToken: "x"}); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("nil refresh %v", err) + } + if err := svc.Logout(ctx); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("logout ctx %v", err) + } +} + +func TestStaticAuthenticate(t *testing.T) { + s := Static{Token: "abc", UserID: 1, DeviceID: 2} + p, err := s.Authenticate(t.Context(), "abc") + if err != nil || p.UserID != 1 || p.DeviceID != 2 { + t.Fatalf("%+v %v", p, err) + } + if _, err := s.Authenticate(t.Context(), "no"); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("bad token %v", err) + } +} + +type kickRec struct{ n int } + +func (k *kickRec) KickDevice(int64, uint64) { k.n++ } + func TestLoginServiceUnauthorized(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret"}) if err != nil { diff --git a/internal/module/conversation/logic/p2p_test.go b/internal/module/conversation/logic/p2p_test.go new file mode 100644 index 0000000..a9a904b --- /dev/null +++ b/internal/module/conversation/logic/p2p_test.go @@ -0,0 +1,106 @@ +package logic + +import ( + "context" + "errors" + "testing" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/module/conversation/entity" + "spark-server/internal/module/conversation/model" + "spark-server/internal/pkg/errs" +) + +func TestEnsureP2PAndUpdateLastMsg(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + repo := model.NewMemoryP2P() + svc := NewP2PService(repo, ids) + ctx := t.Context() + if _, err := svc.EnsureP2P(ctx, 0, 1); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("invalid %v", err) + } + if _, err := svc.EnsureP2P(ctx, 1, 1); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("same user %v", err) + } + cid, err := svc.EnsureP2P(ctx, 1, 2) + if err != nil || cid <= 0 { + t.Fatalf("create %d %v", cid, err) + } + again, err := svc.EnsureP2P(ctx, 2, 1) + if err != nil || again != cid { + t.Fatalf("idempotent %d %v", again, err) + } + if err := svc.UpdateLastMsg(ctx, cid, 99, 1); err != nil { + t.Fatal(err) + } + got, err := repo.Get(ctx, cid) + if err != nil || got.LastMsgID != 99 { + t.Fatalf("last %+v %v", got, err) + } + if err := (*P2PService)(nil).UpdateLastMsg(ctx, cid, 1, 1); !errors.Is(err, errs.ErrAvailable) { + t.Fatalf("nil svc %v", err) + } +} + +type stubP2P struct { + existing *entity.Conversation + getErr error + createErr error +} + +func (s stubP2P) GetP2P(context.Context, int64, int64) (*entity.Conversation, error) { + if s.getErr != nil { + return nil, s.getErr + } + if s.existing != nil { + return s.existing, nil + } + return nil, errs.ErrNotFound +} + +func (s stubP2P) CreateP2P(context.Context, *entity.Conversation, *entity.P2P, []*entity.Member) error { + return s.createErr +} + +func (s stubP2P) UpdateLastMsg(context.Context, int64, int64, int64) error { return nil } + +type dupP2P struct { + gets int + id int64 +} + +func (d *dupP2P) GetP2P(context.Context, int64, int64) (*entity.Conversation, error) { + d.gets++ + if d.gets == 1 { + return nil, errs.ErrNotFound + } + return &entity.Conversation{ID: d.id}, nil +} + +func (d *dupP2P) CreateP2P(context.Context, *entity.Conversation, *entity.P2P, []*entity.Member) error { + return errs.ErrDuplicate +} + +func (d *dupP2P) UpdateLastMsg(context.Context, int64, int64, int64) error { return nil } + +func TestEnsureP2PDuplicateAndErrors(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + ctx := t.Context() + dup := NewP2PService(&dupP2P{id: 9}, ids) + id9, err := dup.EnsureP2P(ctx, 1, 2) + if err != nil || id9 != 9 { + t.Fatalf("dup %d %v", id9, err) + } + if _, err := NewP2PService(stubP2P{getErr: errs.ErrAvailable}, ids).EnsureP2P(ctx, 1, 2); !errors.Is(err, errs.ErrAvailable) { + t.Fatalf("get %v", err) + } + if _, err := NewP2PService(stubP2P{createErr: errs.ErrInner}, ids).EnsureP2P(ctx, 1, 2); !errors.Is(err, errs.ErrInner) { + t.Fatalf("create %v", err) + } +} diff --git a/internal/module/favorite/logic/favorite_test.go b/internal/module/favorite/logic/favorite_test.go index a57df9f..ddc7739 100644 --- a/internal/module/favorite/logic/favorite_test.go +++ b/internal/module/favorite/logic/favorite_test.go @@ -138,3 +138,68 @@ func TestFavoriteForbidsNonMember(t *testing.T) { t.Fatalf("member %v", err) } } + +func TestFavoriteListDeletePurge(t *testing.T) { + svc, msgs, convs, objs, ids := testFav(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"}) + p2p := convlogic.NewP2PService(convs, ids) + convID, err := p2p.EnsureP2P(ctx, 1, 2) + if err != nil { + t.Fatal(err) + } + src := "chat/20260917/1/list.jpg" + objs.keys[src] = true + raw, err := msgentity.NewImage(&msgentity.Image{Key: src, Mime: "image/jpeg"}).Marshal() + if err != nil { + t.Fatal(err) + } + msgID, err := ids.NextID() + if err != nil { + t.Fatal(err) + } + if err := msgs.Create(ctx, &msgentity.Message{ + ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "list1", + Type: msgentity.TypeImage, Content: raw, Status: msgentity.StatusNormal, + CreatedAtMS: time.Now().UnixMilli(), + }); err != nil { + t.Fatal(err) + } + item, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID}) + if err != nil { + t.Fatal(err) + } + listed, err := svc.List(ctx, 1, 20) + if err != nil || listed == nil || len(listed.Items) != 1 || listed.Items[0].ID != item.ID { + t.Fatalf("list %+v %v", listed, err) + } + if _, err := svc.List(ctx, 0, 20); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("list auth %v", err) + } + if err := svc.Delete(ctx, 2, item.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("forbid %v", err) + } + if err := svc.Delete(ctx, 1, item.ID); err != nil { + t.Fatal(err) + } + listed, err = svc.List(ctx, 1, 20) + if err != nil || len(listed.Items) != 0 { + t.Fatalf("after delete %+v %v", listed, err) + } + item2, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID}) + if err != nil { + t.Fatal(err) + } + if err := svc.PurgeUser(ctx, 1); err != nil { + t.Fatal(err) + } + if _, err := svc.List(ctx, 1, 20); err != nil { + t.Fatal(err) + } + listed, _ = svc.List(ctx, 1, 20) + if listed != nil && len(listed.Items) != 0 { + t.Fatalf("purged %+v id=%d", listed, item2.ID) + } + if err := svc.Delete(ctx, 1, 0); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("del zero %v", err) + } +} diff --git a/internal/module/inbox/logic/store_test.go b/internal/module/inbox/logic/store_test.go new file mode 100644 index 0000000..778a7de --- /dev/null +++ b/internal/module/inbox/logic/store_test.go @@ -0,0 +1,29 @@ +package logic + +import ( + "context" + "testing" + + "spark-server/internal/module/inbox/entity" +) + +type nopStore struct{} + +func (nopStore) TouchUnread(context.Context, int64, int64, int64) error { return nil } +func (nopStore) TouchRead(context.Context, int64, int64, int64) error { return nil } +func (nopStore) IncrUnread(context.Context, int64, int64, int64) error { return nil } +func (nopStore) SetPreview(context.Context, int64, []byte) error { return nil } +func (nopStore) ListUpdatedSince(context.Context, int64, int64, int) ([]entity.DialogHit, error) { + return nil, nil +} +func (nopStore) LoadDialogs(context.Context, int64, []entity.DialogHit) ([]entity.Dialog, error) { + return nil, nil +} +func (nopStore) PurgeUser(context.Context, int64) error { return nil } + +func TestStoreContract(t *testing.T) { + var s Store = nopStore{} + if err := s.PurgeUser(t.Context(), 1); err != nil { + t.Fatal(err) + } +} diff --git a/internal/module/message/logic/message_test.go b/internal/module/message/logic/message_test.go index 4c3533a..8069f6d 100644 --- a/internal/module/message/logic/message_test.go +++ b/internal/module/message/logic/message_test.go @@ -238,6 +238,27 @@ func TestP2PRejectsGroupMentions(t *testing.T) { } } +func TestMessageGuards(t *testing.T) { + svc, _, _, _ := testMessageSvc(t) + ctx := t.Context() + if _, err := svc.GetHistory(ctx, 0, HistoryRequest{}); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("hist %v", err) + } + if err := svc.DeleteMessage(ctx, 0, 1, 1); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("del %v", err) + } + if err := svc.RecallMessage(ctx, 0, 1, 1); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("recall %v", err) + } + _, err := svc.SendMessage(ctx, 1, 1, &SendInput{ClientMsgID: "x", Content: json.RawMessage(`{"t":1,"text":{"body":"x"}}`)}) + if !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("no dest %v", err) + } + if err := svc.DeleteMessage(ctx, 1, 1, 0); !errors.Is(err, errs.ErrInvalidArgument) && !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("del zero %v", err) + } +} + func takeCmd(ctx context.Context, q *delivermodel.MemoryQueue, cmd uint16, msgID int64) (*deliverentity.Job, error) { for i := 0; i < 8; i++ { job, err := q.Take(ctx, 50*time.Millisecond) diff --git a/internal/module/moment/logic/moment_test.go b/internal/module/moment/logic/moment_test.go index 4ea1a0e..1a4760c 100644 --- a/internal/module/moment/logic/moment_test.go +++ b/internal/module/moment/logic/moment_test.go @@ -156,6 +156,57 @@ func TestLikeCommentNoticeAndHide(t *testing.T) { } } +func TestUnlikeCommentSettingsAndPurge(t *testing.T) { + svc, friends, users := testMoment(t) + a, b := twoAccounts(t, users) + becomeFriends(t, friends, a, b) + ctxA := ctxUser(t, a.ID, "10.0.0.1") + ctxB := ctxUser(t, b.ID, "10.0.0.2") + post, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hi"}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Like(ctxB, b.ID, post.ID); err != nil { + t.Fatal(err) + } + unliked, err := svc.Unlike(ctxB, b.ID, post.ID) + if err != nil || unliked.Liked || unliked.LikeCount != 0 { + t.Fatalf("unlike %+v %v", unliked, err) + } + commented, err := svc.Comment(ctxB, b.ID, post.ID, &CommentInput{Text: "nice"}) + if err != nil || commented.CommentCount != 1 { + t.Fatalf("comment %+v %v", commented, err) + } + cid := commented.Comments[0].ID + if _, err := svc.DeleteComment(ctxA, a.ID, post.ID, cid); err != nil { + t.Fatal(err) + } + got, err := svc.Get(ctxA, a.ID, post.ID) + if err != nil || got.CommentCount != 0 { + t.Fatalf("deleted comment %+v %v", got, err) + } + days := entity.Visible30Days + st, err := svc.PatchSettings(ctxA, a.ID, &PatchSettingsInput{VisibleDays: &days}) + if err != nil || st.VisibleDays != entity.Visible30Days { + t.Fatalf("settings %+v %v", st, err) + } + if _, err := svc.PutHide(ctxB, b.ID, a.ID, &HideInput{HideHim: true}); err != nil { + t.Fatal(err) + } + if _, err := svc.DeleteHide(ctxB, b.ID, a.ID); err != nil { + t.Fatal(err) + } + if err := svc.ReadNotices(ctxA, a.ID, &NoticeReadInput{Cursor: 0}); err != nil { + t.Fatal(err) + } + if err := svc.PurgeUser(ctxA, a.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.Get(ctxA, a.ID, post.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("purged get %v", err) + } +} + func TestMomentImageKeyRejectedIfChatPrefix(t *testing.T) { svc, _, users := testMoment(t) a, _ := twoAccounts(t, users) diff --git a/internal/module/session/logic/policy_test.go b/internal/module/session/logic/policy_test.go new file mode 100644 index 0000000..b69a101 --- /dev/null +++ b/internal/module/session/logic/policy_test.go @@ -0,0 +1,7 @@ +package logic + +import "testing" + +func TestPolicyExists(_ *testing.T) { + _ = Policy{} +}