diff --git a/.gitignore b/.gitignore index 67021df2..1f9b0e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,9 @@ dumped_hertz_remote_config.json /config/config.yaml /config/config.yaml.bak /k8s/config/configmap.yaml -coverage.txt +**/coverage.txt +**/coverage.out +**/coverage.html ca-key *.jks diff --git a/config/config.go b/config/config.go index 10bab7aa..cce9d760 100644 --- a/config/config.go +++ b/config/config.go @@ -20,6 +20,7 @@ import ( "errors" "log" "os" + "path/filepath" "github.com/fsnotify/fsnotify" "github.com/spf13/viper" @@ -159,3 +160,56 @@ func GetLoggerLevel() string { } return Server.LogLevel } + +// InitForTest 专门用于测试环境的配置初始化 +// 会读取config.example.yaml文件 +func InitForTest(service string) error { + // 寻找项目根目录的config.example.yaml文件 + configPath := findConfigFile("config.example.yaml") + if configPath == "" { + logger.Fatalf("config.InitForTest: config.example.yaml not found") + } + + // 直接指定配置文件的完整路径 + runtimeViper.SetConfigFile(configPath) + + if err := runtimeViper.ReadInConfig(); err != nil { + logger.Fatalf("config.InitForTest: read config error: %v", err) + } + configMapping(service) + + return nil +} + +// findConfigFile 从当前目录开始向上查找配置文件 +func findConfigFile(filename string) string { + // 首先尝试当前目录 + currentDir, err := os.Getwd() + if err != nil { + return "" + } + + // 向上查找直到找到文件或到达根目录 + for { + configPath := filepath.Join(currentDir, "config", filename) + if _, err := os.Stat(configPath); err == nil { + return configPath + } + + // 尝试直接在当前目录查找 + configPath = filepath.Join(currentDir, filename) + if _, err := os.Stat(configPath); err == nil { + return configPath + } + + // 向上一级目录 + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + // 已经到达根目录 + break + } + currentDir = parentDir + } + + return "" +} diff --git a/hack/docker-run.sh b/hack/docker-run.sh index 8937cce1..ed894268 100644 --- a/hack/docker-run.sh +++ b/hack/docker-run.sh @@ -19,6 +19,11 @@ CONFIG_PATH="../config/config.yaml" # related to project folder +# 青果认证配置 +QINGGUO_AUTH_KEY="" +QINGGUO_AUTH_PWD="" +QINGGUO_PROXY_ENABLED="false" + get_port() { local server_name="$1" @@ -78,6 +83,9 @@ start_container() { --network fzu-helper \ -p $server_port:$server_port \ -e ETCD_ADDR="fzu-helper-etcd:2379" \ + -e QINGGUO_AUTH_KEY="$QINGGUO_AUTH_KEY" \ + -e QINGGUO_AUTH_PWD="$QINGGUO_AUTH_PWD" \ + -e QINGGUO_PROXY_ENABLED="$QINGGUO_PROXY_ENABLED" \ --restart always \ $image } diff --git a/internal/academic/service/get_credit_test.go b/internal/academic/service/get_credit_test.go index c8947195..a924e832 100644 --- a/internal/academic/service/get_credit_test.go +++ b/internal/academic/service/get_credit_test.go @@ -22,66 +22,97 @@ import ( "testing" "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" "github.com/west2-online/fzuhelper-server/kitex_gen/model" - meta "github.com/west2-online/fzuhelper-server/pkg/base/context" + baseContext "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/jwch" ) func TestAcademicService_GetCredit(t *testing.T) { - type testCase struct { - name string - mockReturn []*jwch.CreditStatistics - mockError error - expectedResult []*jwch.CreditStatistics - expectingError bool - } - - expectedResult := []*jwch.CreditStatistics{ - { - Type: "Compulsory", - Gain: "4.0", - Total: "8.0", - }, - } - - testCases := []testCase{ - { - name: "GetCreditSuccess", - mockReturn: expectedResult, - mockError: nil, - expectedResult: expectedResult, - }, - { - name: "GetCreditFailure", - mockReturn: nil, - mockError: fmt.Errorf("get credit info fail"), - expectedResult: nil, - expectingError: true, - }, - } - - defer mockey.UnPatchAll() - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockey.Mock((*jwch.Student).GetCredit).Return(tc.mockReturn, tc.mockError).Build() - mockey.Mock(meta.GetLoginData).To(func(ctx context.Context) (*model.LoginData, error) { - return &model.LoginData{ - Id: "1111111111111111111111111111111111", - Cookies: "", - }, nil - }).Build() - academicService := AcademicService{} - result, err := academicService.GetCredit() - if tc.expectingError { - assert.Nil(t, result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Get credit info fail") - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expectedResult, result) + Convey("GetCredit", t, func() { + + Convey("should return error when user is not logged in", func() { + // Given: 未登录的用户上下文 + ctx := context.Background() + service := &AcademicService{ctx: ctx} + + // When: 尝试获取学分信息 + result, err := service.GetCredit() + + // Then: 应该返回登录错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get login data fail") + }) + + Convey("should return error when remote service is unavailable", func() { + // Given: 已登录用户但远程服务不可用 + testLoginData := &model.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", + } + + getCreditPatch := mockey.Mock((*jwch.Student).GetCredit).Return( + nil, fmt.Errorf("network connection failed"), + ).Build() + defer getCreditPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 尝试获取学分信息 + result, err := service.GetCredit() + + // Then: 应该返回网络错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get credit info fail") + }) + + Convey("should return credit statistics when request is successful", func() { + // Given: 已登录用户且系统正常 + testLoginData := &model.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", } + + expectedCreditStats := []*jwch.CreditStatistics{ + { + Type: "公共基础必修课", + Gain: "29.5", + Total: "32", + }, + { + Type: "学科基础必修课", + Gain: "54", + Total: "54", + }, + } + + getCreditPatch := mockey.Mock((*jwch.Student).GetCredit).Return( + expectedCreditStats, nil, + ).Build() + defer getCreditPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 获取学分信息 + result, err := service.GetCredit() + + // Then: 应该返回正确的学分统计数据 + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(len(result), ShouldEqual, 2) + + So(result[0].Type, ShouldEqual, "公共基础必修课") + So(result[0].Gain, ShouldEqual, "29.5") + So(result[0].Total, ShouldEqual, "32") + + So(result[1].Type, ShouldEqual, "学科基础必修课") + So(result[1].Gain, ShouldEqual, "54") + So(result[1].Total, ShouldEqual, "54") }) - } + }) } diff --git a/internal/academic/service/get_gpa_test.go b/internal/academic/service/get_gpa_test.go index b42c8477..f028b19f 100644 --- a/internal/academic/service/get_gpa_test.go +++ b/internal/academic/service/get_gpa_test.go @@ -22,72 +22,96 @@ import ( "testing" "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" "github.com/west2-online/fzuhelper-server/kitex_gen/model" - meta "github.com/west2-online/fzuhelper-server/pkg/base/context" + baseContext "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/jwch" ) func TestAcademicService_GetGPA(t *testing.T) { - type testCase struct { - name string - mockReturn *jwch.GPABean - mockError error - expectedResult *jwch.GPABean - expectingError bool - } - - expectedResult := &jwch.GPABean{ - Time: "2023-06-01", - Data: []jwch.GPAData{ - { - Type: "Mathematics", - Value: "4.0", - }, - { - Type: "Physics", - Value: "3.5", - }, - }, - } - - testCases := []testCase{ - { - name: "GetGPASuccess", - mockReturn: expectedResult, - mockError: nil, - expectedResult: expectedResult, - }, - { - name: "GetGPAFailure", - mockReturn: nil, - mockError: fmt.Errorf("get gpa info fail"), - expectedResult: nil, - expectingError: true, - }, - } - - defer mockey.UnPatchAll() - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockey.Mock((*jwch.Student).GetGPA).Return(tc.mockReturn, tc.mockError).Build() - mockey.Mock(meta.GetLoginData).To(func(ctx context.Context) (*model.LoginData, error) { - return &model.LoginData{ - Id: "1111111111111111111111111111111111", - Cookies: "", - }, nil - }).Build() - academicService := AcademicService{} - result, err := academicService.GetGPA() - if tc.expectingError { - assert.Nil(t, result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Get gpa info fail") - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expectedResult, result) + Convey("GetGPA", t, func() { + + Convey("should return error when user is not logged in", func() { + // Given: 未登录的用户上下文 + ctx := context.Background() + service := &AcademicService{ctx: ctx} + + // When: 尝试获取GPA信息 + result, err := service.GetGPA() + + // Then: 应该返回登录错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get login data fail") + }) + + Convey("should return error when remote service is unavailable", func() { + // Given: 已登录用户但远程服务不可用 + testLoginData := &model.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", } + + getGPAPatch := mockey.Mock((*jwch.Student).GetGPA).Return( + nil, fmt.Errorf("network connection failed"), + ).Build() + defer getGPAPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 尝试获取GPA信息 + result, err := service.GetGPA() + + // Then: 应该返回网络错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get gpa info fail") + }) + + Convey("should return GPA data when request is successful", func() { + // Given: 已登录用户且系统正常 + testLoginData := &model.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + expectedGPA := &jwch.GPABean{ + Time: "2023-06-01", + Data: []jwch.GPAData{ + { + Type: "Mathematics", + Value: "4.0", + }, + { + Type: "Physics", + Value: "3.5", + }, + }, + } + + getGPAPatch := mockey.Mock((*jwch.Student).GetGPA).Return( + expectedGPA, nil, + ).Build() + defer getGPAPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 获取GPA信息 + result, err := service.GetGPA() + + // Then: 应该返回正确的GPA数据 + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Time, ShouldEqual, "2023-06-01") + So(len(result.Data), ShouldEqual, 2) + + So(result.Data[0].Type, ShouldEqual, "Mathematics") + So(result.Data[0].Value, ShouldEqual, "4.0") + So(result.Data[1].Type, ShouldEqual, "Physics") + So(result.Data[1].Value, ShouldEqual, "3.5") }) - } + }) } diff --git a/internal/academic/service/get_plan.go b/internal/academic/service/get_plan.go index 91d2306a..a6cd2aa9 100644 --- a/internal/academic/service/get_plan.go +++ b/internal/academic/service/get_plan.go @@ -17,14 +17,10 @@ limitations under the License. package service import ( - "bytes" - "io" - "net/http" "strings" "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/fzuhelper-server/pkg/errno" - "github.com/west2-online/fzuhelper-server/pkg/logger" "github.com/west2-online/fzuhelper-server/pkg/utils" "github.com/west2-online/jwch" ) @@ -60,27 +56,3 @@ func (s *AcademicService) GetPlan() (string, error) { */ return beforeUrl, nil } - -func getHtmlSource(r *http.Request) (*[]byte, error) { - client := &http.Client{} - resp, err := client.Do(r) - if err != nil { - return nil, err - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logger.Warnf("getHtmlSource:close response body error:%v", err) - } - }(resp.Body) - if resp.StatusCode != http.StatusOK { - return nil, err - } - var buf bytes.Buffer - _, err = buf.ReadFrom(resp.Body) - if err != nil { - return nil, err - } - htmlSource := buf.Bytes() - return &htmlSource, nil -} diff --git a/internal/academic/service/get_plan_test.go b/internal/academic/service/get_plan_test.go index 051fbd8f..5923be5a 100644 --- a/internal/academic/service/get_plan_test.go +++ b/internal/academic/service/get_plan_test.go @@ -19,78 +19,104 @@ package service import ( "context" "fmt" - "net/http" - "strings" "testing" "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" "github.com/west2-online/fzuhelper-server/kitex_gen/model" - meta "github.com/west2-online/fzuhelper-server/pkg/base/context" + baseContext "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/jwch" ) -func TestGetPlan(t *testing.T) { - type testCase struct { - name string - mockFileResult *[]byte - mockUrl string - mockFileError error - expectedResult string - expectedError error - } - mockUrl := "https://www.example.com&id=123456789" - cutUrl, _, _ := strings.Cut(mockUrl, "&id") - mockHtml := []byte(`body { background-color: #fff; }`) - testCases := []testCase{ - { - name: "SuccessCase", - mockFileResult: &mockHtml, - mockUrl: mockUrl, - mockFileError: nil, - expectedResult: cutUrl, - expectedError: nil, - }, - { - name: "NotFound", - mockFileResult: nil, - mockUrl: "", - mockFileError: fmt.Errorf("%s", "cultivate plan not found"), - expectedResult: "", - expectedError: fmt.Errorf("%s", strings.Join([]string{ - "AcademicService.GetPlan", - }, "")), - }, - } - defer mockey.UnPatchAll() - - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockey.Mock((*jwch.Student).WithLoginData).To(func(identifier string, cookies []*http.Cookie) *jwch.Student { - return jwch.NewStudent() - }).Build() - mockey.Mock(meta.GetLoginData).To(func(ctx context.Context) (*model.LoginData, error) { - return &model.LoginData{ - Id: "123456789", - Cookies: "", - }, nil - }).Build() - mockey.Mock((*jwch.Student).GetCultivatePlan).To(func() (string, error) { - return tc.mockUrl, tc.mockFileError - }).Build() - mockey.Mock(getHtmlSource).To(func() (*[]byte, error) { - return tc.mockFileResult, tc.mockFileError - }).Build() - academicService := AcademicService{} - result, err := academicService.GetPlan() - if tc.expectedError != nil { - assert.Contains(t, err.Error(), tc.expectedError.Error()) - } else { - // fmt.Println(string(*result)) - assert.Nil(t, err) - assert.Equal(t, tc.expectedResult, result) +func TestAcademicService_GetPlan(t *testing.T) { + Convey("GetPlan", t, func() { + + Convey("should return error when user is not logged in", func() { + // Given: 未登录的用户上下文 + ctx := context.Background() + service := &AcademicService{ctx: ctx} + + // When: 尝试获取培养计划 + result, err := service.GetPlan() + + // Then: 应该返回登录错误 + So(result, ShouldEqual, "") + So(err, ShouldNotBeNil) + }) + + Convey("should return error when remote service is unavailable", func() { + // Given: 已登录用户但远程服务不可用 + testLoginData := &model.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", + } + + getPlanPatch := mockey.Mock((*jwch.Student).GetCultivatePlan).Return( + "", fmt.Errorf("cultivate plan not found"), + ).Build() + defer getPlanPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 尝试获取培养计划 + result, err := service.GetPlan() + + // Then: 应该返回网络错误 + So(result, ShouldEqual, "") + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "AcademicService.GetPlan") + }) + + Convey("should return error when URL format is invalid", func() { + // Given: 已登录用户但返回的URL格式不正确 + testLoginData := &model.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", } + + getPlanPatch := mockey.Mock((*jwch.Student).GetCultivatePlan).Return( + "https://jwch.fzu.edu.cn/plan/view", nil, // 没有 &id 参数 + ).Build() + defer getPlanPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 尝试获取培养计划 + result, err := service.GetPlan() + + // Then: 应该返回格式错误 + So(result, ShouldEqual, "") + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "AcademicService.GetPlan") + }) + + Convey("should return plan URL when request is successful", func() { + // Given: 已登录用户且系统正常 + testLoginData := &model.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + fullURL := "https://jwch.fzu.edu.cn/plan/view?type=cultivate&id=123456789" + expectedURL := "https://jwch.fzu.edu.cn/plan/view?type=cultivate" + + getPlanPatch := mockey.Mock((*jwch.Student).GetCultivatePlan).Return( + fullURL, nil, + ).Build() + defer getPlanPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 获取培养计划 + result, err := service.GetPlan() + + // Then: 应该返回正确的URL(去掉id参数) + So(err, ShouldBeNil) + So(result, ShouldEqual, expectedURL) }) - } + }) } diff --git a/internal/academic/service/get_scores_test.go b/internal/academic/service/get_scores_test.go index 132a9a11..6eb56c61 100644 --- a/internal/academic/service/get_scores_test.go +++ b/internal/academic/service/get_scores_test.go @@ -22,117 +22,797 @@ import ( "testing" "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" + "github.com/west2-online/jwch" - "github.com/west2-online/fzuhelper-server/kitex_gen/model" + "github.com/west2-online/fzuhelper-server/config" + loginmodel "github.com/west2-online/fzuhelper-server/kitex_gen/model" "github.com/west2-online/fzuhelper-server/pkg/base" - meta "github.com/west2-online/fzuhelper-server/pkg/base/context" + baseContext "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/fzuhelper-server/pkg/cache" academicCache "github.com/west2-online/fzuhelper-server/pkg/cache/academic" + "github.com/west2-online/fzuhelper-server/pkg/db" + academicDB "github.com/west2-online/fzuhelper-server/pkg/db/academic" + dbModel "github.com/west2-online/fzuhelper-server/pkg/db/model" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" - "github.com/west2-online/jwch" + "github.com/west2-online/fzuhelper-server/pkg/umeng" + "github.com/west2-online/fzuhelper-server/pkg/utils" + "github.com/west2-online/yjsy" ) -func TestAcademicService_GetScores(t *testing.T) { - type testCase struct { - name string - mockIsKeyExist bool - mockCacheReturn []*jwch.Mark - mockCacheError error - mockJwchReturn []*jwch.Mark - mockJwchError error - expectedResult []*jwch.Mark - expectingError bool - expectingCacheCall bool +// 测试初始化函数 +func init() { + // 初始化测试配置,这会读取config.example.yaml + if err := config.InitForTest("academic"); err != nil { + panic(fmt.Sprintf("Failed to initialize test config: %v", err)) } +} - expectedResult := []*jwch.Mark{ - { - Name: "Mathematics", - Score: "90", - Credits: "4.0", - GPA: "3.9", - }, - { - Name: "Physics", - Score: "85", - Credits: "3.0", - GPA: "3.6", - }, - } +func TestAcademicService_GetScores(t *testing.T) { + Convey("GetScores", t, func() { - testCases := []testCase{ - { - name: "GetScores from cache success", - mockIsKeyExist: true, - mockCacheReturn: expectedResult, - mockCacheError: nil, - expectedResult: expectedResult, - expectingCacheCall: true, - }, - { - name: "GetScores from cache failure, fallback to GetMarks", - mockIsKeyExist: true, - mockCacheReturn: nil, - mockCacheError: fmt.Errorf("Get scores info fail"), - mockJwchReturn: expectedResult, - mockJwchError: nil, - expectedResult: expectedResult, - expectingError: true, - expectingCacheCall: true, - }, - { - name: "GetScores from GetMarks success", - mockIsKeyExist: false, - mockJwchReturn: expectedResult, - mockJwchError: nil, - expectedResult: expectedResult, - expectingError: false, - expectingCacheCall: false, - }, - { - name: "GetScores from GetMarks failure", - mockIsKeyExist: false, - mockJwchReturn: nil, - mockJwchError: fmt.Errorf("Get scores info fail"), - expectedResult: nil, - expectingError: true, - expectingCacheCall: false, - }, - } + Convey("should return error when user is not logged in", func() { + // Given: 未登录的用户上下文 + ctx := context.Background() + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) - defer mockey.UnPatchAll() - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockClientSet := new(base.ClientSet) - mockClientSet.CacheClient = new(cache.Cache) - mockey.Mock((*jwch.Student).GetMarks).Return(tc.mockJwchReturn, tc.mockJwchError).Build() - mockey.Mock(meta.GetLoginData).To(func(ctx context.Context) (*model.LoginData, error) { - return &model.LoginData{ - Id: "1111111111111111111111111111111111", - Cookies: "", - }, nil - }).Build() - mockey.Mock((*cache.Cache).IsKeyExist).Return(tc.mockIsKeyExist).Build() - if tc.expectingCacheCall { - mockey.Mock((*academicCache.CacheAcademic).GetScoresCache). - Return(tc.mockCacheReturn, tc.mockCacheError). - Build() - } - mockey.Mock((*taskqueue.BaseTaskQueue).Add).Return().Build() - academicService := NewAcademicService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) - result, err := academicService.GetScores(&model.LoginData{ - Id: "123456789", - Cookies: "cookie1=value1;cookie2=value2", + // When: 尝试获取成绩信息 + result, err := service.GetScores(&loginmodel.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", }) - if tc.expectingError { - assert.Nil(t, result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Get scores info fail") - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expectedResult, result) + + // Then: 应该返回登录错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get login data fail") + }) + + Convey("should return scores from cache when cache exists", func() { + // Given: 已登录用户且缓存中有成绩数据 + testLoginData := &loginmodel.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + expectedScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "90", + Credits: "4.0", + GPA: "3.9", + }, + { + Name: "计算机网络", + Score: "85", + Credits: "3.0", + GPA: "3.6", + }, } + + // Mock 缓存存在 + cacheExistsPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(true).Build() + defer cacheExistsPatch.UnPatch() + + // Mock 从缓存获取成绩 + getCachePatch := mockey.Mock((*academicCache.CacheAcademic).GetScoresCache).Return( + expectedScores, nil, + ).Build() + defer getCachePatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 获取成绩信息 + result, err := service.GetScores(testLoginData) + + // Then: 应该返回缓存中的成绩数据 + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(len(result), ShouldEqual, 2) + So(result[0].Name, ShouldEqual, "数据结构") + So(result[0].Score, ShouldEqual, "90") + So(result[1].Name, ShouldEqual, "计算机网络") + So(result[1].Score, ShouldEqual, "85") }) - } + + Convey("should return error when cache exists but cache retrieval fails", func() { + // Given: 已登录用户但缓存读取失败 + testLoginData := &loginmodel.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + // Mock 缓存存在 + cacheExistsPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(true).Build() + defer cacheExistsPatch.UnPatch() + + // Mock 缓存读取失败 + getCachePatch := mockey.Mock((*academicCache.CacheAcademic).GetScoresCache).Return( + nil, fmt.Errorf("redis connection failed"), + ).Build() + defer getCachePatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 尝试获取成绩信息 + result, err := service.GetScores(testLoginData) + + // Then: 应该返回缓存错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get scores info from redis error") + }) + + Convey("should return scores from jwch when cache does not exist", func() { + // Given: 已登录用户且缓存为空,需要从jwch获取 + testLoginData := &loginmodel.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + expectedScores := []*jwch.Mark{ + { + Name: "操作系统", + Score: "88", + Credits: "3.0", + GPA: "3.7", + }, + } + + // Mock 缓存不存在 + cacheExistsPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(false).Build() + defer cacheExistsPatch.UnPatch() + + // Mock jwch 获取成绩成功 + getMarksPatch := mockey.Mock((*jwch.Student).GetMarks).Return( + expectedScores, nil, + ).Build() + defer getMarksPatch.UnPatch() + + // Mock 任务队列(防止真实执行异步任务) + taskQueuePatch := mockey.Mock((*taskqueue.BaseTaskQueue).Add).Return().Build() + defer taskQueuePatch.UnPatch() + + // Mock umeng 推送(防止真实发送推送,因为任务队列可能会触发) + umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(nil).Build() + defer umengAndroidPatch.UnPatch() + + umengIOSPatch := mockey.Mock(umeng.SendIOSGroupcast).Return(nil).Build() + defer umengIOSPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 获取成绩信息 + result, err := service.GetScores(testLoginData) + + // Then: 应该返回从jwch获取的成绩数据 + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(len(result), ShouldEqual, 1) + So(result[0].Name, ShouldEqual, "操作系统") + So(result[0].Score, ShouldEqual, "88") + }) + + Convey("should return error when cache does not exist and jwch service fails", func() { + // Given: 已登录用户,缓存为空,jwch服务不可用 + testLoginData := &loginmodel.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + // Mock 缓存不存在 + cacheExistsPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(false).Build() + defer cacheExistsPatch.UnPatch() + + // Mock jwch 获取成绩失败 + getMarksPatch := mockey.Mock((*jwch.Student).GetMarks).Return( + nil, fmt.Errorf("network connection failed"), + ).Build() + defer getMarksPatch.UnPatch() + + // 注意:jwch失败时不会走到任务队列,所以不需要mock umeng + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 尝试获取成绩信息 + result, err := service.GetScores(testLoginData) + + // Then: 应该返回jwch错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get scores info fail") + }) + }) +} + +func TestAcademicService_checkScoreChange(t *testing.T) { + Convey("checkScoreChange", t, func() { + + Convey("should create new score record when student has no score history", func() { + // Given: 学生没有成绩历史记录 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "90", + Credits: "4.0", + GPA: "3.9", + }, + } + + // Mock 数据库查询返回空的SHA256(表示没有历史记录) + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("", nil).Build() + defer getSha256Patch.UnPatch() + + // Mock 创建用户成绩记录 + createScorePatch := mockey.Mock((*academicDB.DBAcademic).CreateUserScore).Return(&dbModel.Score{ + StuID: "222200311", + ScoresInfo: `[{"name":"数据结构","score":"90","credits":"4.0","gpa":"3.9","semester":"2024-1","teacher":"张老师","electiveType":"必修"}]`, + ScoresInfoSHA256: "new_sha256", + }, nil).Build() + defer createScorePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该成功创建新记录 + So(err, ShouldBeNil) + }) + + Convey("should update score record when scores have changed", func() { + // Given: 学生有成绩历史记录,包含多门课程,部分成绩已更新 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "95", // 分数更新了:90 -> 95 + Credits: "4.0", + GPA: "4.0", + Semester: "2024-1", + Teacher: "张老师", + ElectiveType: "必修", + }, + { + Name: "计算机网络", + Score: "88", // 分数更新了:85 -> 88 + Credits: "3.0", + GPA: "3.7", + Semester: "2024-1", + Teacher: "李老师", + ElectiveType: "必修", + }, + { + Name: "软件工程", + Score: "92", // 分数没有变化,仍然是92 + Credits: "3.5", + GPA: "4.0", + Semester: "2024-1", + Teacher: "王老师", + ElectiveType: "选修", + }, + } + + // Mock 返回旧的SHA256(表示有历史记录) + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("old_sha256", nil).Build() + defer getSha256Patch.UnPatch() + + // Mock 获取旧成绩数据 + getScorePatch := mockey.Mock((*academicDB.DBAcademic).GetScoreByStuId).Return(&dbModel.Score{ + StuID: "222200311", + ScoresInfo: `[{"name":"数据结构","score":"90","credits":"4.0","gpa":"3.9","semester":"2024-1","teacher":"张老师","electiveType":"必修"},{"name":"计算机网络","score":"85","credits":"3.0","gpa":"3.6","semester":"2024-1","teacher":"李老师","electiveType":"必修"},{"name":"软件工程","score":"92","credits":"3.5","gpa":"4.0","semester":"2024-1","teacher":"王老师","electiveType":"选修"}]`, + ScoresInfoSHA256: "old_sha256", + }, nil).Build() + defer getScorePatch.UnPatch() + + // Mock 课程不存在(需要发送推送) + getCourseByHashPatch := mockey.Mock((*academicDB.DBAcademic).GetCourseByHash).Return(nil, nil).Build() + defer getCourseByHashPatch.UnPatch() + + // Mock 创建课程记录 + createCoursePatch := mockey.Mock((*academicDB.DBAcademic).CreateCourseOffering).Return(&dbModel.CourseOffering{}, nil).Build() + defer createCoursePatch.UnPatch() + + // Mock 更新成绩记录 + updateScorePatch := mockey.Mock((*academicDB.DBAcademic).UpdateUserScores).Return(nil).Build() + defer updateScorePatch.UnPatch() + + // Mock umeng 推送 + umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(nil).Build() + defer umengAndroidPatch.UnPatch() + + umengIOSPatch := mockey.Mock(umeng.SendIOSGroupcast).Return(nil).Build() + defer umengIOSPatch.UnPatch() + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该成功更新记录并发送推送 + So(err, ShouldBeNil) + }) + + Convey("should not send notification when course notification already sent", func() { + // Given: 学生成绩更新但已经发送过通知 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "95", + Credits: "4.0", + GPA: "4.0", + Semester: "2024-1", + Teacher: "张老师", + ElectiveType: "必修", + }, + } + + // Mock 返回旧的SHA256 + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("old_sha256", nil).Build() + defer getSha256Patch.UnPatch() + + // Mock 获取旧成绩数据 + getScorePatch := mockey.Mock((*academicDB.DBAcademic).GetScoreByStuId).Return(&dbModel.Score{ + StuID: "222200311", + ScoresInfo: `[{"name":"数据结构","score":"90","credits":"4.0","gpa":"3.9","semester":"2024-1","teacher":"张老师","electiveType":"必修"}]`, + ScoresInfoSHA256: "old_sha256", + }, nil).Build() + defer getScorePatch.UnPatch() + + // Mock 课程存在(已发送过通知) + getCourseByHashPatch := mockey.Mock((*academicDB.DBAcademic).GetCourseByHash).Return(&dbModel.CourseOffering{ + Name: "数据结构", + Term: "2024-1", + Teacher: "张老师", + ElectiveType: "必修", + CourseHash: "test_hash", + }, nil).Build() + defer getCourseByHashPatch.UnPatch() + + // Mock 更新成绩记录 + updateScorePatch := mockey.Mock((*academicDB.DBAcademic).UpdateUserScores).Return(nil).Build() + defer updateScorePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该成功更新记录但不发送推送 + So(err, ShouldBeNil) + }) + + Convey("should do nothing when scores have not changed", func() { + // Given: 学生成绩没有变化 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "90", + Credits: "4.0", + GPA: "3.9", + }, + } + json, err := utils.JSONEncode(testScores) + if err != nil { + t.Fatal(err) + } + sha256 := utils.SHA256(json) + // Mock 返回相同的SHA256(表示成绩没有变化) + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return(sha256, nil).Build() + defer getSha256Patch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err = service.checkScoreChange("222200311", testScores) + + // Then: 应该不做任何操作 + So(err, ShouldBeNil) + }) + + Convey("should return error when GetScoreSha256ByStuId fails", func() { + // Given: 获取SHA256时发生数据库错误 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "90", + Credits: "4.0", + GPA: "3.9", + }, + } + + // Mock 数据库查询返回错误 + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("", fmt.Errorf("database connection failed")).Build() + defer getSha256Patch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该返回数据库错误 + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "database connection failed") + }) + + Convey("should return error when CreateUserScore fails", func() { + // Given: 学生没有成绩历史记录,但创建新记录失败 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "90", + Credits: "4.0", + GPA: "3.9", + }, + } + + // Mock 返回空的SHA256(表示没有历史记录) + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("", nil).Build() + defer getSha256Patch.UnPatch() + + // Mock 创建用户成绩记录失败 + createScorePatch := mockey.Mock((*academicDB.DBAcademic).CreateUserScore).Return(nil, fmt.Errorf("insert failed")).Build() + defer createScorePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该返回创建错误 + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "insert failed") + }) + + Convey("should return error when handleScoreChange fails", func() { + // Given: 成绩已变化,但处理过程中出错 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "95", + Credits: "4.0", + GPA: "4.0", + Semester: "2024-1", + Teacher: "张老师", + ElectiveType: "必修", + }, + } + + // Mock 返回旧的SHA256(表示有历史记录) + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("old_sha256", nil).Build() + defer getSha256Patch.UnPatch() + + // Mock 获取旧成绩数据失败(这会导致handleScoreChange失败) + getScorePatch := mockey.Mock((*academicDB.DBAcademic).GetScoreByStuId).Return(nil, fmt.Errorf("query failed")).Build() + defer getScorePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该返回处理错误 + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "query failed") + }) + + Convey("should return error when UpdateUserScores fails", func() { + // Given: 成绩已变化,但更新数据库失败 + testScores := []*jwch.Mark{ + { + Name: "数据结构", + Score: "95", + Credits: "4.0", + GPA: "4.0", + Semester: "2024-1", + Teacher: "张老师", + ElectiveType: "必修", + }, + } + + // Mock 返回旧的SHA256(表示有历史记录) + getSha256Patch := mockey.Mock((*academicDB.DBAcademic).GetScoreSha256ByStuId).Return("old_sha256", nil).Build() + defer getSha256Patch.UnPatch() + + // Mock 获取旧成绩数据 + getScorePatch := mockey.Mock((*academicDB.DBAcademic).GetScoreByStuId).Return(&dbModel.Score{ + StuID: "222200311", + ScoresInfo: `[{"name":"数据结构","score":"90","credits":"4.0","gpa":"3.9","semester":"2024-1","teacher":"张老师","electiveType":"必修"}]`, + ScoresInfoSHA256: "old_sha256", + }, nil).Build() + defer getScorePatch.UnPatch() + + // Mock 课程不存在(需要发送推送) + getCourseByHashPatch := mockey.Mock((*academicDB.DBAcademic).GetCourseByHash).Return(nil, nil).Build() + defer getCourseByHashPatch.UnPatch() + + // Mock 创建课程记录 + createCoursePatch := mockey.Mock((*academicDB.DBAcademic).CreateCourseOffering).Return(&dbModel.CourseOffering{ + Name: "数据结构", + Term: "2024-1", + Teacher: "张老师", + ElectiveType: "必修", + CourseHash: "test_hash", + }, nil).Build() + defer createCoursePatch.UnPatch() + + // Mock 更新成绩记录失败 + updateScorePatch := mockey.Mock((*academicDB.DBAcademic).UpdateUserScores).Return(fmt.Errorf("update failed")).Build() + defer updateScorePatch.UnPatch() + + // Mock umeng 推送 + umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(nil).Build() + defer umengAndroidPatch.UnPatch() + + umengIOSPatch := mockey.Mock(umeng.SendIOSGroupcast).Return(nil).Build() + defer umengIOSPatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + DBClient: &db.Database{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 检查成绩变化 + err := service.checkScoreChange("222200311", testScores) + + // Then: 应该返回更新错误 + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "update failed") + }) + }) +} + +func TestAcademicService_sendNotifications(t *testing.T) { + Convey("sendNotifications", t, func() { + + Convey("should send notifications to both Android and iOS", func() { + // Given: 准备发送推送的课程信息 + courseName := "数据结构" + tag := "test_tag" + + // Mock umeng 推送成功 + umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(nil).Build() + defer umengAndroidPatch.UnPatch() + + umengIOSPatch := mockey.Mock(umeng.SendIOSGroupcast).Return(nil).Build() + defer umengIOSPatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{} + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 发送通知 + err := service.sendNotifications(courseName, tag) + + // Then: 应该成功发送推送 + So(err, ShouldBeNil) + }) + + Convey("should handle notification errors gracefully", func() { + // Given: 准备发送推送但可能出错 + courseName := "数据结构" + tag := "test_tag" + + // Mock umeng 推送失败 + umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(fmt.Errorf("android push failed")).Build() + defer umengAndroidPatch.UnPatch() + + umengIOSPatch := mockey.Mock(umeng.SendIOSGroupcast).Return(fmt.Errorf("ios push failed")).Build() + defer umengIOSPatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{} + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 发送通知 + err := service.sendNotifications(courseName, tag) + + // Then: 应该成功处理错误(函数内部处理了错误,不会返回错误) + So(err, ShouldBeNil) + }) + }) +} + +func TestAcademicService_GetScoresYjsy(t *testing.T) { + Convey("GetScoresYjsy", t, func() { + + Convey("should return scores from cache when cache exists", func() { + // Given: 缓存中有研究生成绩数据 + testScores := []*yjsy.Mark{ + { + Name: "高等数学", + Score: "95", + Credits: "4.0", + GPA: "4.0", + ExamType: "正常考试", + ElectiveType: "必修", + }, + } + + loginData := &loginmodel.LoginData{ + Id: "202212345678", + Cookies: "test_cookie", + } + + // Mock 缓存存在 + isKeyExistPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(true).Build() + defer isKeyExistPatch.UnPatch() + + // Mock 获取缓存成功 + getCachePatch := mockey.Mock((*academicCache.CacheAcademic).GetScoresCacheYjsy).Return(testScores, nil).Build() + defer getCachePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 调用GetScoresYjsy + scores, err := service.GetScoresYjsy(loginData) + + // Then: 应该返回缓存中的成绩 + So(err, ShouldBeNil) + So(scores, ShouldNotBeNil) + So(len(scores), ShouldEqual, 1) + So(scores[0].Name, ShouldEqual, "高等数学") + }) + + Convey("should return error when cache exists but cache retrieval fails", func() { + // Given: 缓存存在但获取失败 + loginData := &loginmodel.LoginData{ + Id: "202212345678", + Cookies: "test_cookie", + } + + // Mock 缓存存在 + isKeyExistPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(true).Build() + defer isKeyExistPatch.UnPatch() + + // Mock 获取缓存失败 + getCachePatch := mockey.Mock((*academicCache.CacheAcademic).GetScoresCacheYjsy).Return(nil, fmt.Errorf("cache error")).Build() + defer getCachePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 调用GetScoresYjsy + scores, err := service.GetScoresYjsy(loginData) + + // Then: 应该返回错误 + So(err, ShouldNotBeNil) + So(scores, ShouldBeNil) + So(err.Error(), ShouldContainSubstring, "cache error") + }) + + Convey("should return scores from yjsy when cache does not exist", func() { + // Given: 缓存不存在,需要从研究生院系统获取 + testScores := []*yjsy.Mark{ + { + Name: "高等数学", + Score: "95", + Credits: "4.0", + GPA: "4.0", + ExamType: "正常考试", + ElectiveType: "必修", + }, + } + + loginData := &loginmodel.LoginData{ + Id: "202212345678", + Cookies: "test_cookie", + } + + // Mock 缓存不存在 + isKeyExistPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(false).Build() + defer isKeyExistPatch.UnPatch() + + withLoginDataPatch := mockey.Mock((*yjsy.Student).WithLoginData).Return(yjsy.NewStudent()).Build() + defer withLoginDataPatch.UnPatch() + + getMarksPatch := mockey.Mock((*yjsy.Student).GetMarks).Return(testScores, nil).Build() + defer getMarksPatch.UnPatch() + + // Mock 任务队列(防止真实执行异步任务) + taskQueuePatch := mockey.Mock((*taskqueue.BaseTaskQueue).Add).Return().Build() + defer taskQueuePatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 调用GetScoresYjsy + scores, err := service.GetScoresYjsy(loginData) + + // Then: 应该从yjsy获取成绩并返回 + So(err, ShouldBeNil) + So(scores, ShouldNotBeNil) + So(len(scores), ShouldEqual, 1) + So(scores[0].Name, ShouldEqual, "高等数学") + }) + + Convey("should return error when cache does not exist and yjsy service fails", func() { + // Given: 缓存不存在且yjsy服务调用失败 + loginData := &loginmodel.LoginData{ + Id: "202212345678", + Cookies: "test_cookie", + } + + // Mock 缓存不存在 + isKeyExistPatch := mockey.Mock((*cache.Cache).IsKeyExist).Return(false).Build() + defer isKeyExistPatch.UnPatch() + + withLoginDataPatch := mockey.Mock((*yjsy.Student).WithLoginData).Return(yjsy.NewStudent()).Build() + defer withLoginDataPatch.UnPatch() + + // Mock yjsy 获取成绩失败 + getMarksPatch := mockey.Mock((*yjsy.Student).GetMarks).Return(nil, fmt.Errorf("yjsy service error")).Build() + defer getMarksPatch.UnPatch() + + ctx := context.Background() + mockClientSet := &base.ClientSet{ + CacheClient: &cache.Cache{}, + } + service := NewAcademicService(ctx, mockClientSet, &taskqueue.BaseTaskQueue{}) + + // When: 调用GetScoresYjsy + scores, err := service.GetScoresYjsy(loginData) + + // Then: 应该返回错误 + So(err, ShouldNotBeNil) + So(scores, ShouldBeNil) + So(err.Error(), ShouldContainSubstring, "Get scores info fail") + }) + }) } diff --git a/internal/academic/service/get_unified_test.go b/internal/academic/service/get_unified_test.go index e169f5e7..9e28eee8 100644 --- a/internal/academic/service/get_unified_test.go +++ b/internal/academic/service/get_unified_test.go @@ -22,91 +22,144 @@ import ( "testing" "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" "github.com/west2-online/fzuhelper-server/kitex_gen/model" - meta "github.com/west2-online/fzuhelper-server/pkg/base/context" + baseContext "github.com/west2-online/fzuhelper-server/pkg/base/context" "github.com/west2-online/jwch" ) func TestAcademicService_GetUnifiedExam(t *testing.T) { - type testCase struct { - name string - mockCETReturn []*jwch.UnifiedExam - mockJSError error - mockJSReturn []*jwch.UnifiedExam - mockError error - expectedResult []*jwch.UnifiedExam - expectingError bool - expectedErrorMsg string - } - - cetExam := []*jwch.UnifiedExam{ - { - Name: "CET-4", - Score: "520", - Term: "2021年12月", - }, - } - jsExam := []*jwch.UnifiedExam{ - { - Name: "JS", - Score: "90", - Term: "2022年6月", - }, - } - - testCases := []testCase{ - { - name: "GetUnifiedExamSuccess", - mockCETReturn: cetExam, - mockJSReturn: jsExam, - mockError: nil, - expectedResult: append(cetExam, jsExam...), - expectingError: false, - }, - { - name: "GetCETFailure", - mockCETReturn: nil, - mockJSError: fmt.Errorf("Get cet info fail"), - expectedResult: nil, - expectingError: true, - expectedErrorMsg: "Get cet info fail", - }, - { - name: "GetJSFailure", - mockCETReturn: cetExam, - mockJSReturn: nil, - mockError: fmt.Errorf("Get js info fail"), - expectedResult: nil, - expectingError: true, - expectedErrorMsg: "Get js info fail", - }, - } - - defer mockey.UnPatchAll() - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockey.PatchConvey(tc.name, t, func() { - mockey.Mock((*jwch.Student).GetCET).Return(tc.mockCETReturn, tc.mockError).Build() - mockey.Mock((*jwch.Student).GetJS).Return(tc.mockJSReturn, tc.mockJSError).Build() - mockey.Mock(meta.GetLoginData).To(func(ctx context.Context) (*model.LoginData, error) { - return &model.LoginData{ - Id: "1111111111111111111111111111111111", - Cookies: "", - }, nil - }).Build() - academicService := AcademicService{} - result, err := academicService.GetUnifiedExam() - if tc.expectingError { - assert.Nil(t, result) - assert.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrorMsg) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expectedResult, result) - } - }) + Convey("GetUnifiedExam", t, func() { + + Convey("should return error when user is not logged in", func() { + // Given: 未登录的用户上下文 + ctx := context.Background() + service := &AcademicService{ctx: ctx} + + // When: 尝试获取统一考试信息 + result, err := service.GetUnifiedExam() + + // Then: 应该返回登录错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get login data fail") + }) + + Convey("should return error when CET service is unavailable", func() { + // Given: 已登录用户但CET服务不可用 + testLoginData := &model.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", + } + + getCETPatch := mockey.Mock((*jwch.Student).GetCET).Return( + nil, fmt.Errorf("CET service unavailable"), + ).Build() + defer getCETPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 尝试获取统一考试信息 + result, err := service.GetUnifiedExam() + + // Then: 应该返回CET错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get cet info fail") + }) + + Convey("should return error when JS service is unavailable", func() { + // Given: 已登录用户,CET服务正常但JS服务不可用 + testLoginData := &model.LoginData{ + Id: "test_student_id", + Cookies: "test_session=abc123", + } + + cetExams := []*jwch.UnifiedExam{ + { + Name: "CET-4", + Score: "520", + Term: "2023年06月", + }, + } + + getCETPatch := mockey.Mock((*jwch.Student).GetCET).Return( + cetExams, nil, + ).Build() + defer getCETPatch.UnPatch() + + getJSPatch := mockey.Mock((*jwch.Student).GetJS).Return( + nil, fmt.Errorf("JS service unavailable"), + ).Build() + defer getJSPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 尝试获取统一考试信息 + result, err := service.GetUnifiedExam() + + // Then: 应该返回JS错误 + So(result, ShouldBeNil) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Get js info fail") + }) + + Convey("should return unified exam data when request is successful", func() { + // Given: 已登录用户且所有服务正常 + testLoginData := &model.LoginData{ + Id: "222200311", + Cookies: "ASP.NET_SessionId=lzs1t42mpkml4ag2jrxvib4z", + } + + cetExams := []*jwch.UnifiedExam{ + { + Name: "CET-4", + Score: "520", + Term: "2023年06月", + }, + } + + jsExams := []*jwch.UnifiedExam{ + { + Name: "全国计算机等级考试", + Score: "85", + Term: "2023年03月", + }, + } + + getCETPatch := mockey.Mock((*jwch.Student).GetCET).Return( + cetExams, nil, + ).Build() + defer getCETPatch.UnPatch() + + getJSPatch := mockey.Mock((*jwch.Student).GetJS).Return( + jsExams, nil, + ).Build() + defer getJSPatch.UnPatch() + + ctx := baseContext.WithLoginData(context.Background(), testLoginData) + service := &AcademicService{ctx: ctx} + + // When: 获取统一考试信息 + result, err := service.GetUnifiedExam() + + // Then: 应该返回合并的考试数据 + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(len(result), ShouldEqual, 2) + + // 验证CET数据 + So(result[0].Name, ShouldEqual, "CET-4") + So(result[0].Score, ShouldEqual, "520") + So(result[0].Term, ShouldEqual, "2023年06月") + + // 验证JS数据 + So(result[1].Name, ShouldEqual, "全国计算机等级考试") + So(result[1].Score, ShouldEqual, "85") + So(result[1].Term, ShouldEqual, "2023年03月") }) - } + }) } diff --git a/pkg/base/context/login_data.go b/pkg/base/context/login_data.go index fca84956..61ee6d64 100644 --- a/pkg/base/context/login_data.go +++ b/pkg/base/context/login_data.go @@ -54,7 +54,7 @@ func GetLoginData(ctx context.Context) (*model.LoginData, error) { // ExtractIDFromLoginData 从 LoginData 中提取出学号,因为 LoginData 末9位设计为了学号 func ExtractIDFromLoginData(data *model.LoginData) string { - if data.Id == "" || len(data.Id) < constants.StudentIDLength { + if data == nil || data.Id == "" || len(data.Id) < constants.StudentIDLength { return "" } return data.Id[len(data.Id)-constants.StudentIDLength:]