feat: test case

This commit is contained in:
zhr 2026-09-21 15:42:16 +08:00
parent 0e84699df7
commit d36d57692c
14 changed files with 816 additions and 3 deletions

View File

@ -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 ./...

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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}
}

View File

@ -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)
}
}

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,7 @@
package logic
import "testing"
func TestPolicyExists(_ *testing.T) {
_ = Policy{}
}