package controller import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "dubbo.apache.org/dubbo-go/v3/client" "github.com/gin-gonic/gin" "github.com/topfans/backend/pkg/logger" pbAsset "github.com/topfans/backend/pkg/proto/asset" pbCommon "github.com/topfans/backend/pkg/proto/common" "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" ) // TestMain 初始化全局 logger 和 gin 测试模式 // 使用 zap.NewNop() 不输出任何日志,测试结果只靠断言判定 // 注意:本包内 laser_generate_controller_test.go 也有 TestMain, // 为避免冲突,这里统一在此处声明(它将被 go test 收集为唯一的 TestMain) func TestMain(m *testing.M) { gin.SetMode(gin.TestMode) logger.Logger = zap.NewNop() logger.Sugar = logger.Logger.Sugar() m.Run() } // ============================================================ // fakeAssetService —— 实现 pbAsset.AssetService 接口 // 只 stub 关注的 GetAssetQrcode / TrackShare 两个方法, // 其余方法通过嵌入 nil 接口在调用时触发 panic(测试不会触发) // ============================================================ type fakeAssetService struct { pbAsset.AssetService // 嵌入 nil 接口,未 stub 的方法调用时 panic(预期不会发生) // GetAssetQrcode 配置 qrcodeResp *pbAsset.GetAssetQrcodeResponse qrcodeErr error lastQrcodeReq *pbAsset.GetAssetQrcodeRequest qrcodeCallCnt int // TrackShare 配置 trackResp *pbAsset.TrackShareResponse trackErr error lastTrackReq *pbAsset.TrackShareRequest trackCallCnt int } func newFakeAssetService() *fakeAssetService { return &fakeAssetService{} } func (f *fakeAssetService) GetAssetQrcode(ctx context.Context, req *pbAsset.GetAssetQrcodeRequest, opts ...client.CallOption) (*pbAsset.GetAssetQrcodeResponse, error) { f.qrcodeCallCnt++ f.lastQrcodeReq = req if f.qrcodeErr != nil { return nil, f.qrcodeErr } return f.qrcodeResp, nil } func (f *fakeAssetService) TrackShare(ctx context.Context, req *pbAsset.TrackShareRequest, opts ...client.CallOption) (*pbAsset.TrackShareResponse, error) { f.trackCallCnt++ f.lastTrackReq = req if f.trackErr != nil { return nil, f.trackErr } return f.trackResp, nil } // ============================================================ // helper:构造一个带 user_id 的 gin.Context,模拟 JWT 中间件已写入 // ============================================================ func newAuthedContext(userID int64) (*gin.Context, *httptest.ResponseRecorder) { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Set("user_id", userID) return c, rec } // ============================================================ // TestGetAssetQrcode_Success // mock AssetService,GET /share/asset-qrcode/{assetId}?sharer_user_id=42&system_type=ios // user_id=42,期望 200 + JSON 含 qrcode_url + expires_at // ============================================================ func TestGetAssetQrcode_Success(t *testing.T) { fake := newFakeAssetService() fake.qrcodeResp = &pbAsset.GetAssetQrcodeResponse{ Base: &pbCommon.BaseResponse{Code: 0, Message: "ok"}, QrcodeUrl: "https://cdn.example.com/qr/abc.png", ExpiresAt: 1700000000, } ctrl := &AssetController{assetService: fake} c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/share/asset-qrcode/1001?sharer_user_id=42&system_type=ios&share_target=wechat", nil) c.Params = gin.Params{{Key: "assetId", Value: "1001"}} ctrl.GetAssetQrcode(c) if rec.Code != http.StatusOK { t.Fatalf("expected HTTP 200, got %d, body=%s", rec.Code, rec.Body.String()) } var body struct { Code uint32 `json:"code"` Message string `json:"message"` Data interface{} `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("invalid JSON: %v, body=%s", err, rec.Body.String()) } if body.Code != 0 { t.Errorf("expected code=0, got %d (msg=%s)", body.Code, body.Message) } dataMap, ok := body.Data.(map[string]interface{}) if !ok { t.Fatalf("expected data to be object, got %T", body.Data) } if dataMap["qrcode_url"] != "https://cdn.example.com/qr/abc.png" { t.Errorf("qrcode_url mismatch: %v", dataMap["qrcode_url"]) } // expires_at 序列化为 float64 if exp, ok := dataMap["expires_at"].(float64); !ok || int64(exp) != 1700000000 { t.Errorf("expires_at mismatch: %v", dataMap["expires_at"]) } // 断言 fake 收到正确参数 if fake.qrcodeCallCnt != 1 { t.Errorf("expected AssetService.GetAssetQrcode to be called once, got %d", fake.qrcodeCallCnt) } if fake.lastQrcodeReq.GetAssetId() != 1001 { t.Errorf("AssetId mismatch: got %d", fake.lastQrcodeReq.GetAssetId()) } if fake.lastQrcodeReq.GetSharerUserId() != 42 { t.Errorf("SharerUserId mismatch: got %d", fake.lastQrcodeReq.GetSharerUserId()) } if fake.lastQrcodeReq.GetSystemType() != "ios" { t.Errorf("SystemType mismatch: got %q", fake.lastQrcodeReq.GetSystemType()) } if fake.lastQrcodeReq.GetShareTarget() != "wechat" { t.Errorf("ShareTarget mismatch: got %q", fake.lastQrcodeReq.GetShareTarget()) } } // ============================================================ // TestGetAssetQrcode_SharerMismatch // sharer_user_id=999 但 token user_id=42,期望 403 // ============================================================ func TestGetAssetQrcode_SharerMismatch(t *testing.T) { fake := newFakeAssetService() ctrl := &AssetController{assetService: fake} c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/share/asset-qrcode/1001?sharer_user_id=999&system_type=ios", nil) c.Params = gin.Params{{Key: "assetId", Value: "1001"}} ctrl.GetAssetQrcode(c) if rec.Code != http.StatusForbidden { t.Fatalf("expected HTTP 403, got %d, body=%s", rec.Code, rec.Body.String()) } // 鉴权失败 → 不能调下游 service if fake.qrcodeCallCnt != 0 { t.Errorf("expected AssetService NOT called on auth failure, got %d calls", fake.qrcodeCallCnt) } } // ============================================================ // TestGetAssetQrcode_MissingSystemType // 不带 system_type 查询参数,期望 400 // ============================================================ func TestGetAssetQrcode_MissingSystemType(t *testing.T) { fake := newFakeAssetService() ctrl := &AssetController{assetService: fake} c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/share/asset-qrcode/1001?sharer_user_id=42", nil) c.Params = gin.Params{{Key: "assetId", Value: "1001"}} ctrl.GetAssetQrcode(c) if rec.Code != http.StatusBadRequest { t.Fatalf("expected HTTP 400, got %d, body=%s", rec.Code, rec.Body.String()) } if fake.qrcodeCallCnt != 0 { t.Errorf("expected AssetService NOT called, got %d calls", fake.qrcodeCallCnt) } } // ============================================================ // TestGetAssetQrcode_InvalidAssetId // assetId=abc(非数字),期望 400 // ============================================================ func TestGetAssetQrcode_InvalidAssetId(t *testing.T) { fake := newFakeAssetService() ctrl := &AssetController{assetService: fake} c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/share/asset-qrcode/abc?sharer_user_id=42&system_type=ios", nil) c.Params = gin.Params{{Key: "assetId", Value: "abc"}} ctrl.GetAssetQrcode(c) if rec.Code != http.StatusBadRequest { t.Fatalf("expected HTTP 400, got %d, body=%s", rec.Code, rec.Body.String()) } if fake.qrcodeCallCnt != 0 { t.Errorf("expected AssetService NOT called, got %d calls", fake.qrcodeCallCnt) } } // ============================================================ // TestTrackShare_Success // POST /share/track,token user_id=42 == sharer_user_id=42,期望 200 + share_event_id // ============================================================ func TestTrackShare_Success(t *testing.T) { fake := newFakeAssetService() fake.trackResp = &pbAsset.TrackShareResponse{ Base: &pbCommon.BaseResponse{Code: 0, Message: "ok"}, ShareEventId: 8888, } ctrl := &AssetController{assetService: fake} bodyJSON := `{ "asset_id": 1001, "sharer_user_id": 42, "system_type": "ios", "share_target": "wechat", "result": "success", "client_ts": 1700000000 }` c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/share/track", bytes.NewBufferString(bodyJSON)) c.Request.Header.Set("Content-Type", "application/json") ctrl.TrackShare(c) if rec.Code != http.StatusOK { t.Fatalf("expected HTTP 200, got %d, body=%s", rec.Code, rec.Body.String()) } var body struct { Code uint32 `json:"code"` Message string `json:"message"` Data interface{} `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("invalid JSON: %v, body=%s", err, rec.Body.String()) } if body.Code != 0 { t.Errorf("expected code=0, got %d (msg=%s)", body.Code, body.Message) } dataMap, ok := body.Data.(map[string]interface{}) if !ok { t.Fatalf("expected data to be object, got %T", body.Data) } // share_event_id 序列化为 float64 if sid, ok := dataMap["share_event_id"].(float64); !ok || int64(sid) != 8888 { t.Errorf("share_event_id mismatch: %v", dataMap["share_event_id"]) } // 断言 fake 收到正确参数 if fake.trackCallCnt != 1 { t.Errorf("expected AssetService.TrackShare to be called once, got %d", fake.trackCallCnt) } if fake.lastTrackReq.GetAssetId() != 1001 { t.Errorf("AssetId mismatch: got %d", fake.lastTrackReq.GetAssetId()) } if fake.lastTrackReq.GetSharerUserId() != 42 { t.Errorf("SharerUserId mismatch: got %d", fake.lastTrackReq.GetSharerUserId()) } if fake.lastTrackReq.GetSystemType() != "ios" { t.Errorf("SystemType mismatch: got %q", fake.lastTrackReq.GetSystemType()) } if fake.lastTrackReq.GetShareTarget() != "wechat" { t.Errorf("ShareTarget mismatch: got %q", fake.lastTrackReq.GetShareTarget()) } if fake.lastTrackReq.GetResult() != "success" { t.Errorf("Result mismatch: got %q", fake.lastTrackReq.GetResult()) } if fake.lastTrackReq.GetClientTs() != 1700000000 { t.Errorf("ClientTs mismatch: got %d", fake.lastTrackReq.GetClientTs()) } } // ============================================================ // TestTrackShare_SharerMismatch // POST sharer_user_id=999 但 token user_id=42,期望 403 // ============================================================ func TestTrackShare_SharerMismatch(t *testing.T) { fake := newFakeAssetService() ctrl := &AssetController{assetService: fake} bodyJSON := `{ "asset_id": 1001, "sharer_user_id": 999, "system_type": "ios", "share_target": "wechat", "result": "success", "client_ts": 1700000000 }` c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/share/track", bytes.NewBufferString(bodyJSON)) c.Request.Header.Set("Content-Type", "application/json") ctrl.TrackShare(c) if rec.Code != http.StatusForbidden { t.Fatalf("expected HTTP 403, got %d, body=%s", rec.Code, rec.Body.String()) } if fake.trackCallCnt != 0 { t.Errorf("expected AssetService NOT called on auth failure, got %d calls", fake.trackCallCnt) } } // ============================================================ // TestTrackShare_InvalidJSON // POST 带畸形 body,期望 400 // ============================================================ func TestTrackShare_InvalidJSON(t *testing.T) { fake := newFakeAssetService() ctrl := &AssetController{assetService: fake} // 故意给一个不闭合的 JSON,触发 binding error bodyJSON := `{"asset_id": 1001, "sharer_user_id": 42,` c, rec := newAuthedContext(42) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/share/track", bytes.NewBufferString(bodyJSON)) c.Request.Header.Set("Content-Type", "application/json") ctrl.TrackShare(c) if rec.Code != http.StatusBadRequest { t.Fatalf("expected HTTP 400, got %d, body=%s", rec.Code, rec.Body.String()) } if fake.trackCallCnt != 0 { t.Errorf("expected AssetService NOT called on bad JSON, got %d calls", fake.trackCallCnt) } } // 静默引用 structpb,避免 goimports 删除 unused import 警告 var _ = structpb.NewStruct