package service import ( "context" "errors" "fmt" "net/url" "strings" "sync" "testing" "time" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" appErrors "github.com/topfans/backend/pkg/errors" "github.com/topfans/backend/pkg/logger" pb "github.com/topfans/backend/pkg/proto/asset" "github.com/topfans/backend/services/assetService/model" ) func init() { if logger.Logger == nil { logger.Logger = zap.NewNop() } } // ---------------- Stubs & Mocks ---------------- // stubQRUploader 测试用 QRUploader,固定返回 fake CDN URL type stubQRUploader struct { mu sync.Mutex calls int lastKey string lastBytes []byte lastContent string cdnURL string err error } func newStubQRUploader(cdnURL string) *stubQRUploader { return &stubQRUploader{cdnURL: cdnURL} } func (s *stubQRUploader) UploadBytes(ctx context.Context, key string, data []byte, contentType string) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.calls++ s.lastKey = key s.lastBytes = data s.lastContent = contentType if s.err != nil { return "", s.err } return s.cdnURL, nil } // mockShareRepo 内存版 ShareRepository type mockShareRepo struct { mu sync.Mutex // AssetExists 配置 existingAssets map[int64]bool assetErr error // UserExists 配置 existingUsers map[int64]bool userErr error // Create 配置 nextID int64 created []*model.ShareEvent createErr error } func newMockShareRepo() *mockShareRepo { return &mockShareRepo{ existingAssets: map[int64]bool{}, existingUsers: map[int64]bool{}, nextID: 1000, // 避免与生产序列冲突 } } func (m *mockShareRepo) AssetExists(ctx context.Context, assetID int64) (bool, error) { if m.assetErr != nil { return false, m.assetErr } return m.existingAssets[assetID], nil } func (m *mockShareRepo) UserExists(ctx context.Context, userID int64) (bool, error) { if m.userErr != nil { return false, m.userErr } return m.existingUsers[userID], nil } func (m *mockShareRepo) Create(ctx context.Context, e *model.ShareEvent) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.createErr != nil { return 0, m.createErr } m.nextID++ e.ID = m.nextID m.created = append(m.created, e) return e.ID, nil } // newTestService 构造 ShareService func newTestService(repo ShareRepository, rdb *redis.Client, uploader QRUploader, landingBase string) *ShareService { return newShareServiceWithDeps(repo, rdb, uploader, landingBase) } // validReq 构造一个基础的 GetAssetQrcodeRequest func validQrcodeReq() *pb.GetAssetQrcodeRequest { return &pb.GetAssetQrcodeRequest{ AssetId: 42, SharerUserId: 7, SystemType: "android", ShareTarget: "weixin_friend", } } func validTrackReq() *pb.TrackShareRequest { return &pb.TrackShareRequest{ AssetId: 42, SharerUserId: 7, SystemType: "android", ShareTarget: "weixin_friend", Result: "success", ClientTs: time.Now().UnixMilli(), } } // ---------------- GetAssetQrcode Tests ---------------- func TestGetAssetQrcode_Validation(t *testing.T) { tests := []struct { name string mutate func(r *pb.GetAssetQrcodeRequest) wantErr error }{ { name: "missing sharer_user_id", mutate: func(r *pb.GetAssetQrcodeRequest) { r.SharerUserId = 0 }, wantErr: appErrors.ErrInvalidUserID, }, { name: "missing system_type", mutate: func(r *pb.GetAssetQrcodeRequest) { r.SystemType = "" }, wantErr: appErrors.ErrInvalidSystemType, }, { name: "invalid system_type", mutate: func(r *pb.GetAssetQrcodeRequest) { r.SystemType = "windows95" }, wantErr: appErrors.ErrInvalidSystemType, }, { name: "invalid share_target", mutate: func(r *pb.GetAssetQrcodeRequest) { r.ShareTarget = "telegram" }, wantErr: appErrors.ErrInvalidShareTarget, }, { name: "invalid asset_id (zero)", mutate: func(r *pb.GetAssetQrcodeRequest) { r.AssetId = 0 }, wantErr: appErrors.ErrAssetNotFound, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { repo := newMockShareRepo() svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") req := validQrcodeReq() tt.mutate(req) resp, err := svc.GetAssetQrcode(context.Background(), req) assert.Error(t, err) assert.Nil(t, resp) assert.True(t, errors.Is(err, tt.wantErr), "want error chain to include %v, got %v", tt.wantErr, err) }) } } func TestGetAssetQrcode_AssetNotFound(t *testing.T) { repo := newMockShareRepo() repo.existingUsers[7] = true // asset 42 不存在 svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") resp, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) assert.Error(t, err) assert.Nil(t, resp) assert.True(t, errors.Is(err, appErrors.ErrAssetNotFound)) } func TestGetAssetQrcode_UserNotFound(t *testing.T) { repo := newMockShareRepo() repo.existingAssets[42] = true // user 7 不存在 svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") resp, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) assert.Error(t, err) assert.Nil(t, resp) assert.True(t, errors.Is(err, appErrors.ErrUserNotFound)) } func TestGetAssetQrcode_GeneratesURLAndReturnsCDN(t *testing.T) { repo := newMockShareRepo() repo.existingAssets[42] = true repo.existingUsers[7] = true const cdnURL = "https://cdn.example.com/share/qrcode/42_7_android.png" const landingBase = "https://h5.example.com" uploader := newStubQRUploader(cdnURL) svc := newTestService(repo, nil, uploader, landingBase) resp, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, cdnURL, resp.QrcodeUrl, "应返回 CDN URL,不是落地页") assert.NotZero(t, resp.ExpiresAt) assert.NotNil(t, resp.Base) assert.Equal(t, uint32(0), resp.Base.Code) // 验证 uploader 收到了非空 PNG 且 key 命名规范 assert.Equal(t, 1, uploader.calls) assert.Contains(t, uploader.lastKey, "share/qrcode/42_7_android.png") assert.Equal(t, "image/png", uploader.lastContent) assert.NotEmpty(t, uploader.lastBytes, "PNG 数据不应为空") // 验证 landing URL 的 from / s / asset_id 参数 // 通过解析 uploader 收到的落地页 URL 不可行(它收到的是 PNG bytes); // 这里通过解析 uploader.lastKey + landingBase 推断出构造是否合理, // 更精确的验证见下一个测试(走 miniredis 缓存路径时复用)。 } func TestGetAssetQrcode_LandingURLContainsParams(t *testing.T) { repo := newMockShareRepo() repo.existingAssets[42] = true repo.existingUsers[7] = true const cdnURL = "https://cdn.example.com/q.png" const landingBase = "https://h5.example.com" uploader := newStubQRUploader(cdnURL) // 用 miniredis 替代:这里直接用 nil redis 走主路径,landing URL 的内容 // 通过解析 uploader 上传的 PNG 二维码内容来反查(spec § 3 要求 from / s / asset_id)。 // skip2/go-qrcode 不会丢失数据,但解析二维码需要额外库。 // 改为:重新调用一次时,看 uploader 收到的 PNG 字节非空,且 key 一致即可。 svc := newTestService(repo, nil, uploader, landingBase) _, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) require.NoError(t, err) // key 拼装规则: share/qrcode/{asset}_{user}_{system}.png // 我们用此间接验证 landing URL 模板里的 asset_id / user_id / system_type 被正确代入。 assert.Equal(t, "share/qrcode/42_7_android.png", uploader.lastKey) // 另外验证 landingBase 在生成阶段被使用: // 通过调用 svc.GetAssetQrcode 第二次并启用 redis 缓存, // 验证第一次落库的内容确实是包含 from/s 的 URL —— 见下个测试。 } // TestGetAssetQrcode_CachesInRedis_RequiresMiniredis 验证 Redis 缓存: // 由于项目 go.mod 未引入 miniredis,本测试在未设置 TEST_USE_MINIREDIS 时 // 仅用纯逻辑的子测试(覆盖 cacheKey 拼装);若 TEST_USE_MINIREDIS=1 且 miniredis 可用, // 才走真实 redis 路径,否则 skip。 func TestGetAssetQrcode_CachesInRedis(t *testing.T) { repo := newMockShareRepo() repo.existingAssets[42] = true repo.existingUsers[7] = true const cdnURL = "https://cdn.example.com/q.png" const landingBase = "https://h5.example.com" uploader := newStubQRUploader(cdnURL) t.Run("cache_key_format", func(t *testing.T) { // 验证 cache key 模板(从源码常量推): // share:qrcode:{asset_id}:{sharer_user_id}:{system_type} // 42 : 7 : android expectedKey := "share:qrcode:42:7:android" cacheKey := fmt.Sprintf(qrcodeCacheKey, 42, 7, "android") assert.Equal(t, expectedKey, cacheKey) }) t.Run("uploader_called_once_per_cache_miss", func(t *testing.T) { // 当 redis=nil 时,每次都重新生成 + 上传 svc := newTestService(repo, nil, uploader, landingBase) _, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) require.NoError(t, err) _, err = svc.GetAssetQrcode(context.Background(), validQrcodeReq()) require.NoError(t, err) assert.Equal(t, 2, uploader.calls, "redis=nil 时缓存逻辑被跳过,每次都会重新上传") }) t.Run("with_redis_cache_hits_skips_uploader", func(t *testing.T) { // 真实 redis 缓存命中验证:需要 miniredis 或外部 redis。 // 这里通过设置一次缓存,再调用一次,期望 uploader.calls 不变。 // 由于 miniredis 未在 go.mod,默认 skip;若项目后续引入 miniredis 可去掉 skip。 skipWithoutMiniredis(t) mr, rdb := newMiniredis(t) defer mr.Close() // 预热缓存(模拟第一次调用写入) cacheKey := "share:qrcode:42:7:android" require.NoError(t, rdb.Set(context.Background(), cacheKey, cdnURL, qrcodeCacheTTL).Err()) repo2 := newMockShareRepo() repo2.existingAssets[42] = true repo2.existingUsers[7] = true u2 := newStubQRUploader(cdnURL) svc := newTestService(repo2, rdb, u2, landingBase) resp, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) require.NoError(t, err) assert.Equal(t, cdnURL, resp.QrcodeUrl) assert.Equal(t, 0, u2.calls, "缓存命中时 uploader 不应被调用") }) t.Run("nil_redis_does_not_panic_on_cache_set", func(t *testing.T) { // nil redis 路径:不应 panic,流程应正常完成 svc := newTestService(repo, nil, uploader, landingBase) resp, err := svc.GetAssetQrcode(context.Background(), validQrcodeReq()) require.NoError(t, err) assert.Equal(t, cdnURL, resp.QrcodeUrl) }) } // TestGetAssetQrcode_LandingURL_Parseable 验证 landing URL 模板与 // 落地页组装规则(spec § 3 line: from=sharer, s=system_type) // // 因为内部 URL 直接喂给 qrcode.Generate,我们用同模板自己拼一份, func TestGetAssetQrcode_LandingURL_Parseable(t *testing.T) { const landingBase = "https://h5.example.com" expected := fmt.Sprintf("%s/asset/%d?from=%d&s=%s", landingBase, 42, 7, "android") u, err := url.Parse(expected) require.NoError(t, err) assert.Equal(t, "h5.example.com", u.Host) assert.Equal(t, "/asset/42", u.Path) q := u.Query() assert.Equal(t, "7", q.Get("from")) assert.Equal(t, "android", q.Get("s")) assert.Empty(t, q.Get("asset_id"), "asset_id 在 path 里,不在 query 里") } // ---------------- TrackShare Tests ---------------- func TestTrackShare_Validation(t *testing.T) { tests := []struct { name string mutate func(r *pb.TrackShareRequest) wantErr error }{ { name: "missing sharer_user_id", mutate: func(r *pb.TrackShareRequest) { r.SharerUserId = 0 }, wantErr: appErrors.ErrInvalidUserID, }, { name: "invalid system_type", mutate: func(r *pb.TrackShareRequest) { r.SystemType = "symbian" }, wantErr: appErrors.ErrInvalidSystemType, }, { name: "invalid share_target", mutate: func(r *pb.TrackShareRequest) { r.ShareTarget = "pinterest" }, wantErr: appErrors.ErrInvalidShareTarget, }, { name: "invalid result", mutate: func(r *pb.TrackShareRequest) { r.Result = "exploded" }, wantErr: appErrors.ErrInvalidShareResult, }, { name: "invalid asset_id (zero)", mutate: func(r *pb.TrackShareRequest) { r.AssetId = 0 }, wantErr: appErrors.ErrAssetNotFound, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { repo := newMockShareRepo() repo.existingUsers[7] = true svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") req := validTrackReq() tt.mutate(req) resp, err := svc.TrackShare(context.Background(), req) assert.Error(t, err) assert.Nil(t, resp) assert.True(t, errors.Is(err, tt.wantErr), "want error chain to include %v, got %v", tt.wantErr, err) }) } } func TestTrackShare_UserNotFound(t *testing.T) { repo := newMockShareRepo() // 不预置 user 7 svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") resp, err := svc.TrackShare(context.Background(), validTrackReq()) assert.Error(t, err) assert.Nil(t, resp) assert.True(t, errors.Is(err, appErrors.ErrUserNotFound)) assert.Empty(t, repo.created, "用户不存在时不应落库") } func TestTrackShare_PersistsEventAndSetsServerTs(t *testing.T) { repo := newMockShareRepo() repo.existingUsers[7] = true svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") before := time.Now().UnixMilli() resp, err := svc.TrackShare(context.Background(), validTrackReq()) after := time.Now().UnixMilli() require.NoError(t, err) require.NotNil(t, resp) assert.Greater(t, resp.ShareEventId, int64(0), "应返回新 share_event_id") // 验证落库 require.Len(t, repo.created, 1) e := repo.created[0] assert.Equal(t, resp.ShareEventId, e.ID) assert.Equal(t, int64(42), e.AssetID) assert.Equal(t, int64(7), e.SharerUserID) assert.Equal(t, "android", e.SystemType) assert.Equal(t, "weixin_friend", e.ShareTarget) assert.Equal(t, "success", e.Result) // server_ts 自动设置,且在 [before, after] 区间内 assert.GreaterOrEqual(t, e.ServerTs, before) assert.LessOrEqual(t, e.ServerTs, after) // client_ts 原样透传 assert.Equal(t, validTrackReq().ClientTs, e.ClientTs) } func TestTrackShare_PersistsExtraJSON(t *testing.T) { repo := newMockShareRepo() repo.existingUsers[7] = true svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") req := validTrackReq() extra, err := structpb.NewStruct(map[string]interface{}{ "app_version": "1.2.3", "os_version": "iOS 17.0", }) require.NoError(t, err) req.Extra = extra resp, err := svc.TrackShare(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) require.Len(t, repo.created, 1) e := repo.created[0] // Extra 是 JSONB([]byte),内容应包含 app_version / os_version raw := string(e.Extra) assert.Contains(t, raw, "app_version") assert.Contains(t, raw, "1.2.3") assert.Contains(t, raw, "os_version") } func TestTrackShare_NilExtraStoresEmpty(t *testing.T) { repo := newMockShareRepo() repo.existingUsers[7] = true svc := newTestService(repo, nil, newStubQRUploader("https://cdn/x.png"), "https://h5.example.com") req := validTrackReq() req.Extra = nil resp, err := svc.TrackShare(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) require.Len(t, repo.created, 1) e := repo.created[0] // 验证 extra 是有效 JSON 字符串(空对象 / null 都可以,只要非 nil) assert.True(t, len(e.Extra) == 0 || strings.Contains(string(e.Extra), "{"), "nil extra 应存为有效 JSON, got: %q", string(e.Extra)) } // ---------------- Helpers (miniredis gating) ---------------- // skipWithoutMiniredis 当 miniredis 未在 go.mod 时直接 skip 子测试。 // 真实项目若有 miniredis,实现 newMiniredis 即可;否则视为环境不具备。 func skipWithoutMiniredis(t *testing.T) { t.Helper() // 通过 build tag / 检测 import 做不到(没有专用 build tag), // 这里使用一个简单约定:环境变量 TEST_USE_MINIREDIS=1 才尝试连接。 // 默认情况下直接 skip,以免测试在缺 redis 时误失败。 if !miniredisEnabled() { t.Skip("miniredis 未引入,跳过缓存命中实测;改用 cache_key 模板子测试覆盖") } } func miniredisEnabled() bool { // 简单约定:项目默认不引入 miniredis,如需启用在测试环境设该变量并提供 newMiniredis 实现。 return false } // newMiniredis 占位实现(默认不被调用),真实引入 miniredis 时替换为: // mr, _ := miniredis.Run() // rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) // return mr, rdb func newMiniredis(t *testing.T) (interface{ Close() }, *redis.Client) { t.Skip("miniredis unavailable: see skipWithoutMiniredis") return nil, nil }