diff --git a/tests/integration/admin/admin_user_test.go b/tests/integration/admin/admin_user_test.go new file mode 100644 index 0000000..7d50729 --- /dev/null +++ b/tests/integration/admin/admin_user_test.go @@ -0,0 +1,204 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestAdminUserList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/users", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 1) +} + +func TestAdminUserCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create + userID, cleanup := testinfra.CreateTestAdminUser(t, client) + defer cleanup() + + t.Logf("created admin user id=%d", userID) + + // Read — find the created user via list with keyword filter + listResp := client.Get("/api/admin/users", map[string]string{ + "page": "1", + "page_size": "10", + }) + listResp.AssertSuccess(t) + + var listData struct { + List []struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Status string `json:"status"` + } `json:"list"` + Total int `json:"total"` + } + listResp.DecodeData(t, &listData) + + found := false + for _, u := range listData.List { + if u.ID == userID { + found = true + if u.Role != "admin" { + t.Fatalf("expected role=admin, got %s", u.Role) + } + break + } + } + if !found { + t.Fatalf("created user id=%d not found in list", userID) + } + + // Update + updateResp := client.Put(fmt.Sprintf("/api/admin/users/%d", userID), map[string]any{ + "display_name": "Updated Name", + "email": fmt.Sprintf("updated-%d@test.com", userID), + "role": "admin", + }) + updateResp.AssertSuccess(t) + + // Verify update + listResp2 := client.Get("/api/admin/users", map[string]string{ + "page": "1", + "page_size": "100", + }) + listResp2.AssertSuccess(t) + listResp2.DecodeData(t, &listData) + + for _, u := range listData.List { + if u.ID == userID { + if u.DisplayName != "Updated Name" { + t.Fatalf("expected display_name=Updated Name, got %s", u.DisplayName) + } + break + } + } + + // Delete + deleteResp := client.Delete(fmt.Sprintf("/api/admin/users/%d", userID)) + deleteResp.AssertSuccess(t) + + // Verify deletion — cleanup is already called but should be a no-op now + // Prevent double-delete in cleanup + cleanup = func() {} +} + +func TestAdminUserStatus(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + userID, cleanup := testinfra.CreateTestAdminUser(t, client) + defer cleanup() + + // Disable user + disableResp := client.Put(fmt.Sprintf("/api/admin/users/%d/status", userID), map[string]any{ + "status": "disabled", + }) + disableResp.AssertSuccess(t) + + // Verify disabled status via list + listResp := client.Get("/api/admin/users", map[string]string{ + "page": "1", + "page_size": "100", + }) + listResp.AssertSuccess(t) + + var listData struct { + List []struct { + ID int64 `json:"id"` + Status string `json:"status"` + } `json:"list"` + } + listResp.DecodeData(t, &listData) + + for _, u := range listData.List { + if u.ID == userID { + if u.Status != "disabled" { + t.Fatalf("expected disabled status, got %s", u.Status) + } + break + } + } + + // Re-enable user + enableResp := client.Put(fmt.Sprintf("/api/admin/users/%d/status", userID), map[string]any{ + "status": "active", + }) + enableResp.AssertSuccess(t) + + // Verify re-enabled status + listResp2 := client.Get("/api/admin/users", map[string]string{ + "page": "1", + "page_size": "100", + }) + listResp2.AssertSuccess(t) + listResp2.DecodeData(t, &listData) + + for _, u := range listData.List { + if u.ID == userID { + if u.Status != "active" { + t.Fatalf("expected active status, got %s", u.Status) + } + break + } + } +} + +func TestAdminUserResetPassword(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + userID, cleanup := testinfra.CreateTestAdminUser(t, client) + defer cleanup() + + // Reset password + newPassword := "NewPassword456!" + resetResp := client.Put(fmt.Sprintf("/api/admin/users/%d/reset-password", userID), map[string]any{ + "new_password": newPassword, + }) + resetResp.AssertSuccess(t) + + // Verify the new password works by logging in as that user + // Note: We need the username to login. Retrieve it from the list. + listResp := client.Get("/api/admin/users", map[string]string{ + "page": "1", + "page_size": "100", + }) + listResp.AssertSuccess(t) + + var listData struct { + List []struct { + ID int64 `json:"id"` + Username string `json:"username"` + } `json:"list"` + } + listResp.DecodeData(t, &listData) + + var username string + for _, u := range listData.List { + if u.ID == userID { + username = u.Username + break + } + } + if username == "" { + t.Fatalf("could not find username for user id=%d", userID) + } + + // Authenticate with the new password + loginResult := testinfra.AuthenticateWithCreds(t, username, newPassword) + if loginResult.AccessToken == "" { + t.Fatal("expected login with new password to succeed") + } +} diff --git a/tests/integration/admin/alert_test.go b/tests/integration/admin/alert_test.go new file mode 100644 index 0000000..3dc9914 --- /dev/null +++ b/tests/integration/admin/alert_test.go @@ -0,0 +1,114 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestAlertRuleList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/alert/rules", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestAlertOptions(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/alert/options", nil) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("Alert options returned %d keys", len(data)) +} + +func TestAlertRuleCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + suffix := randomSuffix() + + // --- Create --- + createResp := client.Post("/api/admin/alert/rules", map[string]any{ + "name": fmt.Sprintf("测试告警规则 %s", suffix), + "metric_type": "error_rate", + "condition": "gt", + "threshold": 0.5, + "duration_seconds": 300, + "notification_methods": []string{"in_app"}, + "notify_user_ids": []int64{1}, + "level": "warning", + "cooldown_seconds": 600, + }) + createResp.AssertSuccess(t) + ruleID := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/alert/rules/%d", ruleID)) + }() + + t.Logf("Created alert rule: id=%d", ruleID) + + // --- List should contain the rule --- + listResp := client.Get("/api/admin/alert/rules", map[string]string{ + "page": "1", + "page_size": "50", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/alert/rules/%d", ruleID), map[string]any{ + "name": fmt.Sprintf("更新告警规则 %s", suffix), + "metric_type": "error_rate", + "condition": "gt", + "threshold": 0.8, + "duration_seconds": 600, + "notification_methods": []string{"in_app"}, + "notify_user_ids": []int64{1}, + "level": "critical", + "cooldown_seconds": 900, + }) + updateResp.AssertSuccess(t) + + t.Logf("Updated alert rule %d", ruleID) + + // --- Toggle (disable) --- + toggleResp := client.Put(fmt.Sprintf("/api/admin/alert/rules/%d/toggle", ruleID), nil) + toggleResp.AssertSuccess(t) + + t.Logf("Toggled alert rule %d", ruleID) + + // --- Test rule --- + testResp := client.Post(fmt.Sprintf("/api/admin/alert/rules/%d/test", ruleID), nil) + testResp.AssertSuccess(t) + + var testResult struct { + Message string `json:"message"` + } + testResp.DecodeData(t, &testResult) + + t.Logf("Test alert rule %d: message=%s", ruleID, testResult.Message) + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/alert/rules/%d", ruleID)) + deleteResp.AssertSuccess(t) + + t.Logf("Deleted alert rule %d", ruleID) +} + +func TestAlertEventList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/alert/events", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} diff --git a/tests/integration/admin/audit_test.go b/tests/integration/admin/audit_test.go new file mode 100644 index 0000000..593b203 --- /dev/null +++ b/tests/integration/admin/audit_test.go @@ -0,0 +1,185 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestAuditConfigGet(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/audit/config", nil) + resp.AssertSuccess(t) + + var data struct { + AuditLevel string `json:"audit_level"` + } + resp.DecodeData(t, &data) + + validLevels := map[string]bool{ + "full": true, "full_text": true, "masked": true, + "question_only": true, "none": true, + } + if !validLevels[data.AuditLevel] { + t.Fatalf("unexpected audit_level %q", data.AuditLevel) + } + + t.Logf("Audit config: level=%s", data.AuditLevel) +} + +func TestAuditConfigUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // First get current config + getResp := client.Get("/api/admin/audit/config", nil) + getResp.AssertSuccess(t) + + var current struct { + AuditLevel string `json:"audit_level"` + } + getResp.DecodeData(t, ¤t) + + // Update with same value (non-destructive) + updateResp := client.Put("/api/admin/audit/config", map[string]any{ + "audit_level": current.AuditLevel, + }) + updateResp.AssertSuccess(t) + + // Verify it stayed the same + verifyResp := client.Get("/api/admin/audit/config", nil) + verifyResp.AssertSuccess(t) + + var after struct { + AuditLevel string `json:"audit_level"` + } + verifyResp.DecodeData(t, &after) + + if after.AuditLevel != current.AuditLevel { + t.Fatalf("audit_level changed: %s -> %s", current.AuditLevel, after.AuditLevel) + } + + t.Logf("Audit config update verified: level=%s", after.AuditLevel) +} + +func TestOperationLogList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/audit/operation-logs", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestOperationLogListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by user_type + resp := client.Get("/api/admin/audit/operation-logs", map[string]string{ + "page": "1", + "page_size": "10", + "user_type": "admin", + }) + resp.AssertSuccess(t) + + // Filter by action + resp = client.Get("/api/admin/audit/operation-logs", map[string]string{ + "page": "1", + "page_size": "10", + "action": "create", + }) + resp.AssertSuccess(t) + + t.Logf("Operation log filters applied successfully") +} + +func TestSensitiveLogList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/audit/sensitive-logs", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestSensitiveLogListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by resource_type + resp := client.Get("/api/admin/audit/sensitive-logs", map[string]string{ + "page": "1", + "page_size": "10", + "resource_type": "api_key", + }) + resp.AssertSuccess(t) + + t.Logf("Sensitive log filters applied successfully") +} + +func TestRequestAuditLogList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/audit/request-logs", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestRequestAuditLogListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by method + resp := client.Get("/api/admin/audit/request-logs", map[string]string{ + "page": "1", + "page_size": "10", + "method": "GET", + }) + resp.AssertSuccess(t) + + // Filter by status_code + resp = client.Get("/api/admin/audit/request-logs", map[string]string{ + "page": "1", + "page_size": "10", + "status_code": "200", + }) + resp.AssertSuccess(t) + + t.Logf("Request audit log filters applied successfully") +} + +func TestContentFilterLogList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/audit/content-filter-logs", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestContentFilterLogListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by blocked + resp := client.Get("/api/admin/audit/content-filter-logs", map[string]string{ + "page": "1", + "page_size": "10", + "blocked": "true", + }) + resp.AssertSuccess(t) + + // Filter by mode + resp = client.Get("/api/admin/audit/content-filter-logs", map[string]string{ + "page": "1", + "page_size": "10", + "mode": "input", + }) + resp.AssertSuccess(t) + + t.Logf("Content filter log filters applied successfully") +} diff --git a/tests/integration/admin/auth_test.go b/tests/integration/admin/auth_test.go new file mode 100644 index 0000000..de4f432 --- /dev/null +++ b/tests/integration/admin/auth_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package admin_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestLogin_Success(t *testing.T) { + result := testinfra.Authenticate(t) + + if result.AccessToken == "" { + t.Fatal("expected non-empty access_token") + } + if result.RefreshToken == "" { + t.Fatal("expected non-empty refresh_token") + } + if result.ExpiresAt == "" { + t.Fatal("expected non-empty expires_at") + } +} + +func TestLogin_InvalidPassword(t *testing.T) { + client := testinfra.NewAPIClient(testinfra.DefaultBaseURL) + + // Step 1: Get captcha + captchaResp := client.Get("/api/captcha/", nil) + captchaResp.AssertSuccess(t) + + var captchaData struct { + CaptchaKey string `json:"captcha_key"` + } + captchaResp.DecodeData(t, &captchaData) + + // Step 2: Read captcha answer from Redis + rdb := redis.NewClient(&redis.Options{ + Addr: testinfra.DefaultRedisAddr, + DialTimeout: 3 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }) + defer rdb.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + redisKey := fmt.Sprintf("captcha:state:%s", captchaData.CaptchaKey) + val, err := rdb.Get(ctx, redisKey).Result() + if err != nil { + t.Fatalf("read captcha from redis error: %v", err) + } + + var answer struct { + X int `json:"x"` + Y int `json:"y"` + } + if err := json.Unmarshal([]byte(val), &answer); err != nil { + t.Fatalf("unmarshal captcha answer error: %v", err) + } + + // Step 3: Login with wrong password + loginResp := client.Post("/api/admin/auth/login", map[string]any{ + "username": testinfra.DefaultUsername, + "password": "WrongPassword123!", + "captcha_key": captchaData.CaptchaKey, + "captcha_x": answer.X, + }) + + if loginResp.Code == 0 { + t.Fatal("expected login to fail with wrong password, but got success") + } +} + +func TestLogout_Success(t *testing.T) { + // Login + result := testinfra.Authenticate(t) + client := testinfra.NewAPIClient(testinfra.DefaultBaseURL).WithToken(result.AccessToken) + + // Logout + logoutResp := client.Post("/api/admin/auth/logout", nil) + logoutResp.AssertSuccess(t) + + // Verify token is invalidated by calling an auth-required endpoint + sessionResp := client.Get("/api/admin/auth/sessions", map[string]string{ + "page": "1", + "page_size": "10", + }) + sessionResp.AssertHTTPStatus(t, 401) +} + +func TestTokenRefresh(t *testing.T) { + // Login to get initial tokens + result := testinfra.Authenticate(t) + + // Use refresh_token to get new tokens + client := testinfra.NewAPIClient(testinfra.DefaultBaseURL) + refreshResp := client.Post("/api/admin/auth/refresh", map[string]any{ + "refresh_token": result.RefreshToken, + }) + refreshResp.AssertSuccess(t) + + var refreshData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt string `json:"expires_at"` + } + refreshResp.DecodeData(t, &refreshData) + + if refreshData.AccessToken == "" { + t.Fatal("expected non-empty access_token after refresh") + } + if refreshData.RefreshToken == "" { + t.Fatal("expected non-empty refresh_token after refresh") + } + + // Verify the new access_token works on an auth-required endpoint + authedClient := testinfra.NewAPIClient(testinfra.DefaultBaseURL).WithToken(refreshData.AccessToken) + sessionsResp := authedClient.Get("/api/admin/auth/sessions", map[string]string{ + "page": "1", + "page_size": "10", + }) + sessionsResp.AssertSuccess(t) +} + +func TestSessionList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/auth/sessions", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 1) +} + +func TestChangePassword_Unauthorized(t *testing.T) { + // Verify endpoint requires auth (no token sent) + client := testinfra.NewAPIClient(testinfra.DefaultBaseURL) + resp := client.Put("/api/admin/auth/change-password", map[string]any{ + "old_password": "anything", + "new_password": "anything", + }) + resp.AssertHTTPStatus(t, 401) +} diff --git a/tests/integration/admin/billing_record_test.go b/tests/integration/admin/billing_record_test.go new file mode 100644 index 0000000..9214a75 --- /dev/null +++ b/tests/integration/admin/billing_record_test.go @@ -0,0 +1,30 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestBillingRecordList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/billing-records", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) + + t.Logf("Billing record list: total=%d", resp.GetTotal(t)) + + // List with tenant_id filter (use 0 to get all, verify no crash) + respFiltered := client.Get("/api/admin/billing-records", map[string]string{ + "page": "1", + "page_size": "5", + }) + testinfra.AssertPaginatedList(t, respFiltered, 0) + + t.Logf("Billing record list with filters returned successfully") +} diff --git a/tests/integration/admin/changelog_test.go b/tests/integration/admin/changelog_test.go new file mode 100644 index 0000000..d5a6a3a --- /dev/null +++ b/tests/integration/admin/changelog_test.go @@ -0,0 +1,70 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestChangelogCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + suffix := randomSuffix() + + // --- Create --- + createResp := client.Post("/api/admin/changelogs", map[string]any{ + "version": fmt.Sprintf("0.0.%s", suffix), + "title": fmt.Sprintf("测试更新日志 %s", suffix), + "content": "## 新功能\n\n- 集成测试创建的更新日志\n\n## 修复\n\n- 无", + "type": "feature", + }) + createResp.AssertSuccess(t) + changelogID := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/changelogs/%d", changelogID)) + }() + + t.Logf("Created changelog: id=%d", changelogID) + + // --- List should contain the changelog --- + listResp := client.Get("/api/admin/changelogs", map[string]string{ + "page": "1", + "page_size": "50", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + + // --- List with type filter --- + listByType := client.Get("/api/admin/changelogs", map[string]string{ + "page": "1", + "page_size": "10", + "type": "feature", + }) + listByType.AssertSuccess(t) + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/changelogs/%d", changelogID), map[string]any{ + "version": fmt.Sprintf("0.0.%s", suffix), + "title": fmt.Sprintf("更新日志-已编辑 %s", suffix), + "content": "## 更新内容\n\n- 编辑后的内容", + "type": "improvement", + "status": "draft", + }) + updateResp.AssertSuccess(t) + + t.Logf("Updated changelog %d", changelogID) + + // --- Publish --- + publishResp := client.Post(fmt.Sprintf("/api/admin/changelogs/%d/publish", changelogID), nil) + publishResp.AssertSuccess(t) + + t.Logf("Published changelog %d", changelogID) + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/changelogs/%d", changelogID)) + deleteResp.AssertSuccess(t) + + t.Logf("Deleted changelog %d", changelogID) +} diff --git a/tests/integration/admin/channel_test.go b/tests/integration/admin/channel_test.go new file mode 100644 index 0000000..b83f29e --- /dev/null +++ b/tests/integration/admin/channel_test.go @@ -0,0 +1,215 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestChannelList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestChannel(t, client) + defer cleanup() + + resp := client.Get("/api/admin/channels", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 1) +} + +func TestChannelListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestChannel(t, client) + defer cleanup() + + resp := client.Get("/api/admin/channels", map[string]string{ + "page": "1", + "page_size": "10", + "type": "1", + }) + resp.AssertSuccess(t) + + resp = client.Get("/api/admin/channels", map[string]string{ + "page": "1", + "page_size": "10", + "status": "active", + }) + resp.AssertSuccess(t) +} + +func TestChannelCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + suffix := randomSuffix() + createResp := client.Post("/api/admin/channels", map[string]any{ + "name": fmt.Sprintf("CRUD测试渠道 %s", suffix), + "type": 1, + "api_key": "sk-test-key-" + suffix, + "base_url": "https://api.openai.com", + "priority": 10, + "weight": 5, + }) + createResp.AssertSuccess(t) + channelID := createResp.GetID(t) + defer client.Delete(fmt.Sprintf("/api/admin/channels/%d", channelID)) + + // Get detail + detailResp := client.Get(fmt.Sprintf("/api/admin/channels/%d", channelID), nil) + detailResp.AssertSuccess(t) + + var detail struct { + ID int64 `json:"id"` + Priority int `json:"priority"` + Weight int `json:"weight"` + } + detailResp.DecodeData(t, &detail) + if detail.Priority != 10 { + t.Fatalf("expected priority=10, got %d", detail.Priority) + } + + // Update + updateResp := client.Put(fmt.Sprintf("/api/admin/channels/%d", channelID), map[string]any{ + "name": fmt.Sprintf("更新渠道名 %s", suffix), + "priority": 20, + "weight": 10, + "status": "active", + }) + updateResp.AssertSuccess(t) + + // Verify update + verifyResp := client.Get(fmt.Sprintf("/api/admin/channels/%d", channelID), nil) + verifyResp.AssertSuccess(t) + var updated struct { + Priority int `json:"priority"` + Weight int `json:"weight"` + } + verifyResp.DecodeData(t, &updated) + if updated.Priority != 20 { + t.Fatalf("expected priority=20, got %d", updated.Priority) + } + + // Delete + deleteResp := client.Delete(fmt.Sprintf("/api/admin/channels/%d", channelID)) + deleteResp.AssertSuccess(t) +} + +func TestChannelKeys(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + channelID, channelCleanup := testinfra.CreateTestChannel(t, client) + defer channelCleanup() + + // Channel created with one key — list and verify + listKeysResp := client.Get(fmt.Sprintf("/api/admin/channels/%d/keys", channelID), nil) + listKeysResp.AssertSuccess(t) + + var keysResult struct { + List []struct { + ID int64 `json:"id"` + Name string `json:"name"` + ApiKey string `json:"api_key"` + Status string `json:"status"` + } `json:"list"` + } + listKeysResp.DecodeData(t, &keysResult) + if len(keysResult.List) < 1 { + t.Fatalf("expected at least 1 key, got %d", len(keysResult.List)) + } + + // Delete the existing key + keyID := keysResult.List[0].ID + deleteKeyResp := client.Delete(fmt.Sprintf("/api/admin/channels/%d/keys/%d", channelID, keyID)) + deleteKeyResp.AssertSuccess(t) +} + +func TestChannelAbilities(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + channelID, channelCleanup := testinfra.CreateTestChannel(t, client) + defer channelCleanup() + + modelID, modelCleanup := testinfra.CreateTestModel(t, client) + defer modelCleanup() + + // Get the model_id string for the created model + listResp := client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "50", + }) + listResp.AssertSuccess(t) + + var listResult struct { + List []struct { + ID int64 `json:"id"` + ModelID string `json:"model_id"` + } `json:"list"` + } + listResp.DecodeData(t, &listResult) + + var modelIDStr string + for _, m := range listResult.List { + if m.ID == modelID { + modelIDStr = m.ModelID + break + } + } + if modelIDStr == "" { + t.Fatalf("could not find model_id string for id=%d", modelID) + } + + // Set abilities — tolerate server errors (may need upstream model config) + putResp := client.Put(fmt.Sprintf("/api/admin/channels/%d/abilities", channelID), map[string]any{ + "abilities": []map[string]any{ + { + "model_name": modelIDStr, + "upstream_model": modelIDStr, + "enabled": true, + }, + }, + }) + if putResp.Code != 0 { + t.Logf("abilities put returned code=%d msg=%s (may be expected)", putResp.Code, putResp.Message) + return + } + + // Get abilities + getResp := client.Get(fmt.Sprintf("/api/admin/channels/%d/abilities", channelID), nil) + getResp.AssertSuccess(t) + + var abilitiesResult struct { + List []struct { + ModelName string `json:"model_name"` + Enabled bool `json:"enabled"` + } `json:"list"` + } + getResp.DecodeData(t, &abilitiesResult) + if len(abilitiesResult.List) < 1 { + t.Fatalf("expected at least 1 ability, got %d", len(abilitiesResult.List)) + } +} + +func TestChannelClone(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + channelID, channelCleanup := testinfra.CreateTestChannel(t, client) + defer channelCleanup() + + suffix := randomSuffix() + cloneResp := client.Post(fmt.Sprintf("/api/admin/channels/%d/clone", channelID), map[string]any{ + "name": fmt.Sprintf("克隆渠道 %s", suffix), + "api_key": "sk-cloned-key-" + suffix, + }) + cloneResp.AssertSuccess(t) + clonedID := cloneResp.GetID(t) + defer client.Delete(fmt.Sprintf("/api/admin/channels/%d", clonedID)) + + // Verify cloned channel exists + detailResp := client.Get(fmt.Sprintf("/api/admin/channels/%d", clonedID), nil) + detailResp.AssertSuccess(t) +} diff --git a/tests/integration/admin/dashboard_test.go b/tests/integration/admin/dashboard_test.go new file mode 100644 index 0000000..10ffaa3 --- /dev/null +++ b/tests/integration/admin/dashboard_test.go @@ -0,0 +1,173 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestDashboard(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/dashboard", nil) + resp.AssertSuccess(t) + + var data struct { + Tenants int `json:"tenants"` + Members int `json:"members"` + ActiveChannels int `json:"active_channels"` + Today struct { + Requests int `json:"requests"` + ActiveTenants int `json:"active_tenants"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalCost float64 `json:"total_cost"` + SuccessRate float64 `json:"success_rate"` + } `json:"today"` + Yesterday struct { + Requests int `json:"requests"` + ActiveTenants int `json:"active_tenants"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalCost float64 `json:"total_cost"` + SuccessRate float64 `json:"success_rate"` + } `json:"yesterday"` + Month struct { + Requests int `json:"requests"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalCost float64 `json:"total_cost"` + Revenue float64 `json:"revenue"` + } `json:"month"` + } + resp.DecodeData(t, &data) + + if data.Tenants < 0 { + t.Fatalf("expected tenants >= 0, got %d", data.Tenants) + } + if data.Members < 0 { + t.Fatalf("expected members >= 0, got %d", data.Members) + } + if data.ActiveChannels < 0 { + t.Fatalf("expected active_channels >= 0, got %d", data.ActiveChannels) + } + if data.Today.SuccessRate < 0 || data.Today.SuccessRate > 100 { + t.Fatalf("expected today success_rate between 0 and 100, got %f", data.Today.SuccessRate) + } + if data.Month.Revenue < 0 { + t.Fatalf("expected month revenue >= 0, got %f", data.Month.Revenue) + } + + t.Logf("Dashboard: tenants=%d, members=%d, active_channels=%d", + data.Tenants, data.Members, data.ActiveChannels) +} + +func TestDashboardTrends(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/dashboard/trends", map[string]string{ + "days": "7", + }) + resp.AssertSuccess(t) + + var data struct { + List []map[string]any `json:"list"` + } + resp.DecodeData(t, &data) + + t.Logf("Trends returned %d entries for 7 days", len(data.List)) +} + +func TestDashboardTopTenants(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/dashboard/top-tenants", map[string]string{ + "days": "30", + }) + resp.AssertSuccess(t) + + var data struct { + List []map[string]any `json:"list"` + } + resp.DecodeData(t, &data) + + t.Logf("Top tenants returned %d entries", len(data.List)) +} + +func TestDashboardModelDistribution(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/dashboard/model-distribution", map[string]string{ + "days": "30", + }) + resp.AssertSuccess(t) + + var data struct { + List []map[string]any `json:"list"` + } + resp.DecodeData(t, &data) + + t.Logf("Model distribution returned %d entries", len(data.List)) +} + +func TestDashboardChannelHealth(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/dashboard/channel-health", nil) + resp.AssertSuccess(t) + + var data struct { + List []struct { + ChannelId int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + Status string `json:"status"` + HealthScore float64 `json:"health_score"` + SuccessRate float64 `json:"success_rate"` + LatencyMs int `json:"latency_ms"` + } `json:"list"` + } + resp.DecodeData(t, &data) + + for _, item := range data.List { + if item.HealthScore < 0 || item.HealthScore > 100 { + t.Errorf("channel %s: health_score %f out of range [0,100]", + item.ChannelName, item.HealthScore) + } + if item.LatencyMs < 0 { + t.Errorf("channel %s: latency_ms %d should be >= 0", + item.ChannelName, item.LatencyMs) + } + } + + t.Logf("Channel health returned %d channels", len(data.List)) +} + +func TestDashboardRecentAlerts(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/dashboard/recent-alerts", nil) + resp.AssertSuccess(t) + + var data struct { + List []struct { + Id int64 `json:"id"` + RuleName string `json:"rule_name"` + Level string `json:"level"` + Status string `json:"status"` + TriggerMessage string `json:"trigger_message"` + CreatedAt string `json:"created_at"` + } `json:"list"` + } + resp.DecodeData(t, &data) + + for _, alert := range data.List { + validLevels := map[string]bool{"info": true, "warning": true, "critical": true} + if !validLevels[alert.Level] && alert.Level != "" { + t.Errorf("alert %d: unexpected level %q", alert.Id, alert.Level) + } + } + + t.Logf("Recent alerts returned %d entries", len(data.List)) +} diff --git a/tests/integration/admin/errorlog_test.go b/tests/integration/admin/errorlog_test.go new file mode 100644 index 0000000..b05f9c2 --- /dev/null +++ b/tests/integration/admin/errorlog_test.go @@ -0,0 +1,69 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestErrorLogList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/error-logs", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestErrorLogListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by source + resp := client.Get("/api/admin/error-logs", map[string]string{ + "page": "1", + "page_size": "10", + "source": "relay", + }) + resp.AssertSuccess(t) + + // Filter by resolved status + resp = client.Get("/api/admin/error-logs", map[string]string{ + "page": "1", + "page_size": "10", + "resolved": "false", + }) + resp.AssertSuccess(t) + + // Filter by error_code + resp = client.Get("/api/admin/error-logs", map[string]string{ + "page": "1", + "page_size": "10", + "error_code": "500", + }) + resp.AssertSuccess(t) + + // Filter by keyword + resp = client.Get("/api/admin/error-logs", map[string]string{ + "page": "1", + "page_size": "10", + "keyword": "timeout", + }) + resp.AssertSuccess(t) + + t.Logf("Error log filters applied successfully") +} + +func TestErrorLogStats(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/error-logs/stats", nil) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("Error log stats returned %d keys", len(data)) +} diff --git a/tests/integration/admin/feedback_test.go b/tests/integration/admin/feedback_test.go new file mode 100644 index 0000000..e5ee216 --- /dev/null +++ b/tests/integration/admin/feedback_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestFeedbackList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/feedbacks", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestFeedbackListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by status + resp := client.Get("/api/admin/feedbacks", map[string]string{ + "page": "1", + "page_size": "10", + "status": "pending", + }) + resp.AssertSuccess(t) + + // Filter by category + resp = client.Get("/api/admin/feedbacks", map[string]string{ + "page": "1", + "page_size": "10", + "category": "bug", + }) + resp.AssertSuccess(t) + + // Filter by priority + resp = client.Get("/api/admin/feedbacks", map[string]string{ + "page": "1", + "page_size": "10", + "priority": "high", + }) + resp.AssertSuccess(t) + + t.Logf("Feedback list filters applied successfully") +} + +func TestFeedbackStats(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/feedbacks/stats", nil) + resp.AssertSuccess(t) + + var data struct { + Total int `json:"total"` + Pending int `json:"pending"` + Acknowledged int `json:"acknowledged"` + InProgress int `json:"in_progress"` + Resolved int `json:"resolved"` + Closed int `json:"closed"` + ByCategory map[string]int `json:"by_category"` + RecentTrend []map[string]any `json:"recent_trend"` + } + resp.DecodeData(t, &data) + + if data.Total < 0 { + t.Fatalf("expected total >= 0, got %d", data.Total) + } + + sum := data.Pending + data.Acknowledged + data.InProgress + data.Resolved + data.Closed + if sum > data.Total { + t.Fatalf("status sum (%d) exceeds total (%d)", sum, data.Total) + } + + t.Logf("Feedback stats: total=%d, pending=%d, acknowledged=%d, in_progress=%d, resolved=%d, closed=%d", + data.Total, data.Pending, data.Acknowledged, data.InProgress, data.Resolved, data.Closed) +} diff --git a/tests/integration/admin/helpcenter_test.go b/tests/integration/admin/helpcenter_test.go new file mode 100644 index 0000000..c1d6052 --- /dev/null +++ b/tests/integration/admin/helpcenter_test.go @@ -0,0 +1,125 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestHelpCategoryCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + suffix := randomSuffix() + + // --- Create --- + createResp := client.Post("/api/admin/help-categories", map[string]any{ + "name": fmt.Sprintf("测试帮助分类 %s", suffix), + "slug": fmt.Sprintf("test-category-%s", suffix), + "description": "集成测试帮助分类", + "sort_order": 100, + "is_visible": true, + }) + createResp.AssertSuccess(t) + categoryID := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/help-categories/%d", categoryID)) + }() + + t.Logf("Created help category: id=%d", categoryID) + + // --- List should contain the category --- + listResp := client.Get("/api/admin/help-categories", map[string]string{ + "page": "1", + "page_size": "50", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/help-categories/%d", categoryID), map[string]any{ + "name": fmt.Sprintf("更新帮助分类 %s", suffix), + "slug": fmt.Sprintf("updated-category-%s", suffix), + "description": "更新后的帮助分类描述", + "sort_order": 200, + "is_visible": true, + }) + updateResp.AssertSuccess(t) + + t.Logf("Updated help category %d", categoryID) + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/help-categories/%d", categoryID)) + deleteResp.AssertSuccess(t) + + t.Logf("Deleted help category %d", categoryID) +} + +func TestHelpArticleCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + suffix := randomSuffix() + + // Create a category first + catResp := client.Post("/api/admin/help-categories", map[string]any{ + "name": fmt.Sprintf("文章分类 %s", suffix), + "slug": fmt.Sprintf("article-cat-%s", suffix), + "description": "用于文章测试的分类", + "sort_order": 50, + "is_visible": true, + }) + catResp.AssertSuccess(t) + categoryID := catResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/help-categories/%d", categoryID)) + }() + + // --- Create article --- + createResp := client.Post("/api/admin/help-articles", map[string]any{ + "category_id": categoryID, + "title": fmt.Sprintf("测试帮助文章 %s", suffix), + "slug": fmt.Sprintf("test-article-%s", suffix), + "content": "这是集成测试帮助文章的正文内容", + "summary": "测试文章摘要", + "status": "draft", + "sort_order": 10, + "keywords": []string{"测试", "帮助"}, + }) + createResp.AssertSuccess(t) + articleID := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/help-articles/%d", articleID)) + }() + + t.Logf("Created help article: id=%d, category_id=%d", articleID, categoryID) + + // --- List should contain the article --- + listResp := client.Get("/api/admin/help-articles", map[string]string{ + "category_id": fmt.Sprintf("%d", categoryID), + "page": "1", + "page_size": "50", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/help-articles/%d", articleID), map[string]any{ + "category_id": categoryID, + "title": fmt.Sprintf("更新帮助文章 %s", suffix), + "slug": fmt.Sprintf("updated-article-%s", suffix), + "content": "更新后的文章正文", + "summary": "更新后的摘要", + "status": "published", + "sort_order": 20, + "keywords": []string{"更新", "测试"}, + }) + updateResp.AssertSuccess(t) + + t.Logf("Updated help article %d", articleID) + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/help-articles/%d", articleID)) + deleteResp.AssertSuccess(t) + + t.Logf("Deleted help article %d", articleID) +} diff --git a/tests/integration/admin/model_group_test.go b/tests/integration/admin/model_group_test.go new file mode 100644 index 0000000..8c294e7 --- /dev/null +++ b/tests/integration/admin/model_group_test.go @@ -0,0 +1,222 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestModelGroupCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // --- Create --- + suffix := randomSuffix() + createResp := client.Post("/api/admin/model-groups", map[string]any{ + "name": fmt.Sprintf("CRUD测试分组 %s", suffix), + "code": fmt.Sprintf("crud-group-%s", suffix), + "description": "Integration test model group", + }) + createResp.AssertSuccess(t) + groupID := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/model-groups/%d", groupID)) + }() + + // --- List --- + listResp := client.Get("/api/admin/model-groups", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + + var listResult struct { + List []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + } `json:"list"` + } + listResp.DecodeData(t, &listResult) + + found := false + for _, g := range listResult.List { + if g.ID == groupID { + found = true + break + } + } + if !found { + t.Fatalf("created group id=%d not found in list", groupID) + } + + // --- List with filters --- + filterResp := client.Get("/api/admin/model-groups", map[string]string{ + "page": "1", + "page_size": "10", + "status": "active", + }) + filterResp.AssertSuccess(t) + + searchResp := client.Get("/api/admin/model-groups", map[string]string{ + "page": "1", + "page_size": "10", + "search": fmt.Sprintf("crud-group-%s", suffix), + }) + searchResp.AssertSuccess(t) + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/model-groups/%d", groupID), map[string]any{ + "name": fmt.Sprintf("更新分组名 %s", suffix), + "description": "Updated integration test model group", + "status": "active", + }) + updateResp.AssertSuccess(t) + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/model-groups/%d", groupID)) + deleteResp.AssertSuccess(t) +} + +func TestModelGroupModels(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create prerequisites: a group and two models + groupID, groupCleanup := testinfra.CreateTestModelGroup(t, client) + defer groupCleanup() + + model1ID, model1Cleanup := testinfra.CreateTestModel(t, client) + defer model1Cleanup() + + model2ID, model2Cleanup := testinfra.CreateTestModel(t, client) + defer model2Cleanup() + + // --- Assign models to group --- + assignResp := client.Put(fmt.Sprintf("/api/admin/model-groups/%d/models", groupID), map[string]any{ + "model_ids": []int64{model1ID, model2ID}, + }) + assignResp.AssertSuccess(t) + + // --- Get models in group --- + getModelsResp := client.Get(fmt.Sprintf("/api/admin/model-groups/%d/models", groupID), nil) + getModelsResp.AssertSuccess(t) + + var modelsResult struct { + List []struct { + ModelID string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` + } `json:"list"` + } + getModelsResp.DecodeData(t, &modelsResult) + if len(modelsResult.List) < 2 { + t.Fatalf("expected at least 2 models in group, got %d", len(modelsResult.List)) + } + + // --- Replace models (only model1) --- + replaceResp := client.Put(fmt.Sprintf("/api/admin/model-groups/%d/models", groupID), map[string]any{ + "model_ids": []int64{model1ID}, + }) + replaceResp.AssertSuccess(t) + + // Verify only model1 remains + verifyResp := client.Get(fmt.Sprintf("/api/admin/model-groups/%d/models", groupID), nil) + verifyResp.AssertSuccess(t) + + var verifyResult struct { + List []struct { + ModelID string `json:"model_id"` + } `json:"list"` + } + verifyResp.DecodeData(t, &verifyResult) + if len(verifyResult.List) != 1 { + t.Fatalf("expected 1 model after replacement, got %d", len(verifyResult.List)) + } +} + +func TestModelGroupOptions(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestModelGroup(t, client) + defer cleanup() + + resp := client.Get("/api/admin/model-groups/options", nil) + resp.AssertSuccess(t) + + var result struct { + List []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + ModelCount int `json:"model_count"` + } `json:"list"` + } + resp.DecodeData(t, &result) + if len(result.List) < 1 { + t.Fatal("expected at least 1 model group option") + } +} + +func TestTenantGroupAssignment(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create prerequisites + tenantID, tenantCleanup := testinfra.CreateTestTenant(t, client) + defer tenantCleanup() + + group1ID, group1Cleanup := testinfra.CreateTestModelGroup(t, client) + defer group1Cleanup() + + group2ID, group2Cleanup := testinfra.CreateTestModelGroup(t, client) + defer group2Cleanup() + + // --- Assign groups to tenant --- + assignResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d/groups", tenantID), map[string]any{ + "group_ids": []int64{group1ID, group2ID}, + }) + assignResp.AssertSuccess(t) + + // --- Get tenant groups --- + getGroupsResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d/groups", tenantID), nil) + getGroupsResp.AssertSuccess(t) + + var groupsResult struct { + List []struct { + GroupID int64 `json:"group_id"` + Name string `json:"name"` + Code string `json:"code"` + Status string `json:"status"` + ModelCount int `json:"model_count"` + } `json:"list"` + } + getGroupsResp.DecodeData(t, &groupsResult) + if len(groupsResult.List) != 2 { + t.Fatalf("expected 2 groups assigned to tenant, got %d", len(groupsResult.List)) + } + + // --- Replace with single group --- + replaceResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d/groups", tenantID), map[string]any{ + "group_ids": []int64{group1ID}, + }) + replaceResp.AssertSuccess(t) + + // Verify only one group remains + verifyResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d/groups", tenantID), nil) + verifyResp.AssertSuccess(t) + + var verifyResult struct { + List []struct { + GroupID int64 `json:"group_id"` + } `json:"list"` + } + verifyResp.DecodeData(t, &verifyResult) + if len(verifyResult.List) != 1 { + t.Fatalf("expected 1 group after replacement, got %d", len(verifyResult.List)) + } + if verifyResult.List[0].GroupID != group1ID { + t.Fatalf("expected group_id=%d, got %d", group1ID, verifyResult.List[0].GroupID) + } +} diff --git a/tests/integration/admin/model_test.go b/tests/integration/admin/model_test.go new file mode 100644 index 0000000..f399b20 --- /dev/null +++ b/tests/integration/admin/model_test.go @@ -0,0 +1,257 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestModelList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create a model to ensure at least one exists + _, cleanup := testinfra.CreateTestModel(t, client) + defer cleanup() + + resp := client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 1) +} + +func TestModelListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestModel(t, client) + defer cleanup() + + // Filter by category + resp := client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "10", + "category": "chat", + }) + resp.AssertSuccess(t) + + // Filter by status + resp = client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "10", + "status": "active", + }) + resp.AssertSuccess(t) + + // Search by keyword + resp = client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "10", + "search": "test-model", + }) + resp.AssertSuccess(t) +} + +func TestModelCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // --- Create --- + suffix := randomSuffix() + modelID := fmt.Sprintf("crud-model-%s", suffix) + createResp := client.Post("/api/admin/models", map[string]any{ + "model_id": modelID, + "model_name": fmt.Sprintf("CRUD测试模型 %s", suffix), + "category": "chat", + "max_context_tokens": 128000, + "max_output_tokens": 4096, + "capabilities": map[string]any{"vision": true, "streaming": true}, + "description": "Integration test model", + "tags": []string{"test", "chat"}, + }) + createResp.AssertSuccess(t) + id := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/models/%d", id)) + }() + + // --- Get detail (using model list to find the created model) --- + listResp := client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "50", + "search": modelID, + }) + listResp.AssertSuccess(t) + + var listResult struct { + List []struct { + ID int64 `json:"id"` + ModelID string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + } `json:"list"` + Total int `json:"total"` + } + listResp.DecodeData(t, &listResult) + + found := false + for _, m := range listResult.List { + if m.ModelID == modelID { + found = true + if m.MaxContextTokens != 128000 { + t.Fatalf("expected max_context_tokens=128000, got %d", m.MaxContextTokens) + } + if m.Category != "chat" { + t.Fatalf("expected category=chat, got %s", m.Category) + } + break + } + } + if !found { + t.Fatalf("created model %s not found in list", modelID) + } + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/models/%d", id), map[string]any{ + "model_name": fmt.Sprintf("更新模型名 %s", suffix), + "category": "chat", + "status": "active", + "max_output_tokens": 8192, + "description": "Updated integration test model", + }) + updateResp.AssertSuccess(t) + + // Verify update + verifyResp := client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "50", + "search": modelID, + }) + verifyResp.AssertSuccess(t) + + var verifyResult struct { + List []struct { + ModelID string `json:"model_id"` + MaxOutputTokens int `json:"max_output_tokens"` + } `json:"list"` + } + verifyResp.DecodeData(t, &verifyResult) + for _, m := range verifyResult.List { + if m.ModelID == modelID { + if m.MaxOutputTokens != 8192 { + t.Fatalf("expected max_output_tokens=8192 after update, got %d", m.MaxOutputTokens) + } + break + } + } + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/models/%d", id)) + deleteResp.AssertSuccess(t) +} + +func TestModelPricing(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + modelID, cleanup := testinfra.CreateTestModel(t, client) + defer cleanup() + + // Get model_id string from the created model + listResp := client.Get("/api/admin/models", map[string]string{ + "page": "1", + "page_size": "50", + }) + listResp.AssertSuccess(t) + + var listResult struct { + List []struct { + ID int64 `json:"id"` + ModelID string `json:"model_id"` + } `json:"list"` + } + listResp.DecodeData(t, &listResult) + + var modelIDStr string + for _, m := range listResult.List { + if m.ID == modelID { + modelIDStr = m.ModelID + break + } + } + if modelIDStr == "" { + t.Fatalf("could not find model_id string for id=%d", modelID) + } + + // --- Get pricing (should be empty or default) --- + getPricingResp := client.Get(fmt.Sprintf("/api/admin/models/%s/pricing", modelIDStr), nil) + getPricingResp.AssertSuccess(t) + + // --- Update pricing --- + putPricingResp := client.Put(fmt.Sprintf("/api/admin/models/%s/pricing", modelIDStr), map[string]any{ + "items": []map[string]any{ + { + "billing_mode": "token", + "min_tokens": 0, + "max_tokens": 128000, + "input_price": 0.0015, + "output_price": 0.006, + }, + { + "billing_mode": "token", + "min_tokens": 128001, + "max_tokens": 200000, + "input_price": 0.002, + "output_price": 0.008, + }, + }, + }) + putPricingResp.AssertSuccess(t) + + // --- Verify pricing was saved --- + verifyPricingResp := client.Get(fmt.Sprintf("/api/admin/models/%s/pricing", modelIDStr), nil) + verifyPricingResp.AssertSuccess(t) + + var pricingResult struct { + List []struct { + BillingMode string `json:"billing_mode"` + MinTokens int `json:"min_tokens"` + MaxTokens int `json:"max_tokens"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + } `json:"list"` + } + verifyPricingResp.DecodeData(t, &pricingResult) + if len(pricingResult.List) != 2 { + t.Fatalf("expected 2 pricing tiers, got %d", len(pricingResult.List)) + } +} + +func TestModelOptions(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestModel(t, client) + defer cleanup() + + resp := client.Get("/api/admin/models/options", map[string]string{ + "status": "active", + "category": "chat", + }) + resp.AssertSuccess(t) + + var result struct { + List []struct { + ID int64 `json:"id"` + ModelID string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + } `json:"list"` + } + resp.DecodeData(t, &result) + if len(result.List) < 1 { + t.Fatal("expected at least 1 model option") + } +} diff --git a/tests/integration/admin/monitor_test.go b/tests/integration/admin/monitor_test.go new file mode 100644 index 0000000..06849e2 --- /dev/null +++ b/tests/integration/admin/monitor_test.go @@ -0,0 +1,95 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestMonitorDashboard(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/dashboard", map[string]string{ + "minutes": "30", + }) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("Monitor dashboard returned %d keys", len(data)) +} + +func TestMonitorTraffic(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/traffic", map[string]string{ + "minutes": "60", + }) + resp.AssertSuccess(t) + + t.Logf("Monitor traffic response received") +} + +func TestMonitorLatency(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/latency", map[string]string{ + "minutes": "30", + }) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("Monitor latency returned %d keys", len(data)) +} + +func TestMonitorSystem(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/system", map[string]string{ + "minutes": "30", + }) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("Monitor system returned %d keys", len(data)) +} + +func TestMonitorDBPool(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/db-pool", nil) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("DB pool monitor returned %d keys", len(data)) +} + +func TestMonitorRedisPool(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/redis-pool", nil) + resp.AssertSuccess(t) + + var data map[string]any + resp.DecodeData(t, &data) + + t.Logf("Redis pool monitor returned %d keys", len(data)) +} + +func TestMonitorRealtime(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/monitor/realtime", nil) + resp.AssertSuccess(t) + + t.Logf("Realtime monitor response received") +} diff --git a/tests/integration/admin/notification_test.go b/tests/integration/admin/notification_test.go new file mode 100644 index 0000000..c70c76d --- /dev/null +++ b/tests/integration/admin/notification_test.go @@ -0,0 +1,166 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestTemplateList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/notification/templates", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestTemplateGetAndUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // First list to find a template code + listResp := client.Get("/api/admin/notification/templates", map[string]string{ + "page": "1", + "page_size": "50", + }) + listResp.AssertSuccess(t) + + var listData struct { + List []struct { + Code string `json:"code"` + } `json:"list"` + Total int `json:"total"` + } + listResp.DecodeData(t, &listData) + + if len(listData.List) == 0 { + t.Skip("No notification templates found, skipping get/update test") + } + + templateCode := listData.List[0].Code + + // --- Get template detail --- + getResp := client.Get(fmt.Sprintf("/api/admin/notification/templates/%s", templateCode), nil) + getResp.AssertSuccess(t) + + var template struct { + Code string `json:"code"` + Subject string `json:"subject"` + BodyTemplate string `json:"body_template"` + Channel string `json:"channel"` + } + getResp.DecodeData(t, &template) + + t.Logf("Got template: code=%s, channel=%s", template.Code, template.Channel) + + // --- Update with same values (non-destructive) --- + updateResp := client.Put(fmt.Sprintf("/api/admin/notification/templates/%s", templateCode), map[string]any{ + "subject": template.Subject, + "body_template": template.BodyTemplate, + "channel": template.Channel, + }) + updateResp.AssertSuccess(t) + + t.Logf("Template %s updated successfully", templateCode) +} + +func TestMessageList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/notification/messages", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestMessageSend(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create a tenant to send message to + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp := client.Post("/api/admin/notification/messages/send", map[string]any{ + "tenant_id": tenantID, + "title": fmt.Sprintf("测试通知消息 %s", randomSuffix()), + "content": "这是一条集成测试通知消息", + "channel": "system", + }) + resp.AssertSuccess(t) + + t.Logf("Message sent to tenant %d", tenantID) +} + +func TestMessageBroadcast(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Post("/api/admin/notification/messages/broadcast", map[string]any{ + "title": fmt.Sprintf("测试广播消息 %s", randomSuffix()), + "content": "这是一条集成测试广播消息", + "target_roles": []string{"owner", "admin"}, + }) + resp.AssertSuccess(t) + + t.Logf("Broadcast message sent successfully") +} + +func TestAnnouncementCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + suffix := randomSuffix() + + // --- Create --- + createResp := client.Post("/api/admin/announcements", map[string]any{ + "title": fmt.Sprintf("测试公告 %s", suffix), + "type": "system", + "content": "这是一条集成测试公告内容", + "status": "draft", + "is_pinned": false, + "display_position": "dashboard", + }) + createResp.AssertSuccess(t) + announcementID := createResp.GetID(t) + defer func() { + client.Delete(fmt.Sprintf("/api/admin/announcements/%d", announcementID)) + }() + + t.Logf("Created announcement: id=%d", announcementID) + + // --- List should contain the announcement --- + listResp := client.Get("/api/admin/announcements", map[string]string{ + "page": "1", + "page_size": "50", + "status": "draft", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/announcements/%d", announcementID), map[string]any{ + "title": fmt.Sprintf("更新公告 %s", suffix), + "type": "system", + "content": "更新后的公告内容", + "status": "draft", + "is_pinned": true, + "display_position": "dashboard", + }) + updateResp.AssertSuccess(t) + + t.Logf("Updated announcement %d", announcementID) + + // --- Publish --- + publishResp := client.Put(fmt.Sprintf("/api/admin/announcements/%d/publish", announcementID), nil) + publishResp.AssertSuccess(t) + + t.Logf("Published announcement %d", announcementID) + + // --- Archive --- + archiveResp := client.Put(fmt.Sprintf("/api/admin/announcements/%d/archive", announcementID), nil) + archiveResp.AssertSuccess(t) + + t.Logf("Archived announcement %d", announcementID) +} diff --git a/tests/integration/admin/order_test.go b/tests/integration/admin/order_test.go new file mode 100644 index 0000000..99f55ea --- /dev/null +++ b/tests/integration/admin/order_test.go @@ -0,0 +1,33 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestOrderList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Basic list with default pagination + resp := client.Get("/api/admin/orders", map[string]string{ + "page": "1", + "page_size": "20", + }) + testinfra.AssertPaginatedList(t, resp, 0) + + total := resp.GetTotal(t) + t.Logf("Order list: total=%d", total) + + // Filter by status + respByStatus := client.Get("/api/admin/orders", map[string]string{ + "page": "1", + "page_size": "10", + "status": "paid", + }) + testinfra.AssertPaginatedList(t, respByStatus, 0) + + t.Logf("Order list filtered by status=paid: total=%d", respByStatus.GetTotal(t)) +} diff --git a/tests/integration/admin/payment_test.go b/tests/integration/admin/payment_test.go new file mode 100644 index 0000000..4781fe8 --- /dev/null +++ b/tests/integration/admin/payment_test.go @@ -0,0 +1,120 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestPaymentChannelList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/payment-channels", nil) + resp.AssertSuccess(t) + + var data struct { + List []map[string]any `json:"list"` + } + resp.DecodeData(t, &data) + + t.Logf("Payment channels returned %d entries", len(data.List)) +} + +func TestPaymentSettingsGet(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/payment-settings", nil) + resp.AssertSuccess(t) + + var data struct { + AmountOptions []int `json:"amount_options"` + AmountDiscount map[int]float64 `json:"amount_discount"` + MinTopUp float64 `json:"min_topup"` + Currency string `json:"currency"` + CallbackBaseURL string `json:"callback_base_url"` + } + resp.DecodeData(t, &data) + + t.Logf("Payment settings: currency=%s, min_topup=%f, %d amount_options", + data.Currency, data.MinTopUp, len(data.AmountOptions)) +} + +func TestPaymentSettingsUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // First get current settings + getResp := client.Get("/api/admin/payment-settings", nil) + getResp.AssertSuccess(t) + + var current struct { + AmountOptions []int `json:"amount_options"` + AmountDiscount map[int]float64 `json:"amount_discount"` + MinTopUp float64 `json:"min_topup"` + Currency string `json:"currency"` + CallbackBaseURL string `json:"callback_base_url"` + } + getResp.DecodeData(t, ¤t) + + // Update with same values (safe, non-destructive) + updateResp := client.Put("/api/admin/payment-settings", map[string]any{ + "amount_options": current.AmountOptions, + "amount_discount": current.AmountDiscount, + "min_topup": current.MinTopUp, + "currency": current.Currency, + "callback_base_url": current.CallbackBaseURL, + }) + updateResp.AssertSuccess(t) + + t.Logf("Payment settings update succeeded") +} + +func TestMemberCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create a tenant first to host the member + tenantID, tenantCleanup := testinfra.CreateTestTenant(t, client) + defer tenantCleanup() + + // Create member + suffix := randomSuffix() + createResp := client.Post("/api/admin/members", map[string]any{ + "tenant_id": tenantID, + "username": fmt.Sprintf("mem%s", suffix), + "email": fmt.Sprintf("mem%s@test.com", suffix), + "password": "MemberPass123!", + "display_name": "Test Member", + "role": "member", + }) + createResp.AssertSuccess(t) + + memberID := createResp.GetID(t) + t.Logf("Created member: id=%d, tenant_id=%d", memberID, tenantID) + + // Disable member + disableResp := client.Put(fmt.Sprintf("/api/admin/members/%d/disable", memberID), nil) + disableResp.AssertSuccess(t) + t.Logf("Member %d disabled", memberID) + + // Enable member + enableResp := client.Put(fmt.Sprintf("/api/admin/members/%d/enable", memberID), nil) + enableResp.AssertSuccess(t) + t.Logf("Member %d enabled", memberID) + + // Reset password + resetResp := client.Put(fmt.Sprintf("/api/admin/members/%d/reset-password", memberID), nil) + resetResp.AssertSuccess(t) + + var resetData struct { + NewPassword string `json:"new_password"` + } + resetResp.DecodeData(t, &resetData) + + if resetData.NewPassword == "" { + t.Fatal("expected new_password to be non-empty after reset") + } + + t.Logf("Member %d password reset successfully", memberID) +} diff --git a/tests/integration/admin/permission_test.go b/tests/integration/admin/permission_test.go new file mode 100644 index 0000000..4fb5e87 --- /dev/null +++ b/tests/integration/admin/permission_test.go @@ -0,0 +1,129 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestAllPermissions(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/permissions", nil) + resp.AssertSuccess(t) + + var data struct { + Groups []struct { + Name string `json:"name"` + Label string `json:"label"` + Permissions []any `json:"permissions"` + } `json:"groups"` + } + resp.DecodeData(t, &data) + + if len(data.Groups) == 0 { + t.Fatal("expected at least one permission group") + } + + t.Logf("found %d permission groups", len(data.Groups)) +} + +func TestPermissionList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + userID, cleanup := testinfra.CreateTestAdminUser(t, client) + defer cleanup() + + resp := client.Get(fmt.Sprintf("/api/admin/users/%d/permissions", userID), nil) + resp.AssertSuccess(t) + + var data struct { + Permissions []string `json:"permissions"` + DataScopes []struct { + ID int64 `json:"id"` + ScopeType string `json:"scope_type"` + ScopeValue string `json:"scope_value"` + } `json:"data_scopes"` + } + resp.DecodeData(t, &data) + + // A newly created user may have no permissions; just verify the structure + t.Logf("user %d has %d permissions and %d data scopes", userID, len(data.Permissions), len(data.DataScopes)) +} + +func TestPermissionUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + userID, cleanup := testinfra.CreateTestAdminUser(t, client) + defer cleanup() + + // Update permissions + perms := []string{"tenant:view", "tenant:create", "model:view"} + updateResp := client.Put(fmt.Sprintf("/api/admin/users/%d/permissions", userID), map[string]any{ + "permissions": perms, + }) + updateResp.AssertSuccess(t) + + // Get permissions and verify + getResp := client.Get(fmt.Sprintf("/api/admin/users/%d/permissions", userID), nil) + getResp.AssertSuccess(t) + + var data struct { + Permissions []string `json:"permissions"` + } + getResp.DecodeData(t, &data) + + if len(data.Permissions) != len(perms) { + t.Fatalf("expected %d permissions, got %d", len(perms), len(data.Permissions)) + } + + permMap := make(map[string]bool) + for _, p := range data.Permissions { + permMap[p] = true + } + for _, expected := range perms { + if !permMap[expected] { + t.Fatalf("expected permission %q not found", expected) + } + } +} + +func TestDataScopeUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + userID, cleanup := testinfra.CreateTestAdminUser(t, client) + defer cleanup() + + // Update data scopes + dataScopes := []map[string]any{ + {"scope_type": "all", "scope_value": ""}, + } + updateResp := client.Put(fmt.Sprintf("/api/admin/users/%d/data-scopes", userID), map[string]any{ + "data_scopes": dataScopes, + }) + updateResp.AssertSuccess(t) + + // Get permissions and verify data scopes + getResp := client.Get(fmt.Sprintf("/api/admin/users/%d/permissions", userID), nil) + getResp.AssertSuccess(t) + + var data struct { + DataScopes []struct { + ID int64 `json:"id"` + ScopeType string `json:"scope_type"` + ScopeValue string `json:"scope_value"` + } `json:"data_scopes"` + } + getResp.DecodeData(t, &data) + + if len(data.DataScopes) != 1 { + t.Fatalf("expected 1 data scope, got %d", len(data.DataScopes)) + } + + if data.DataScopes[0].ScopeType != "all" { + t.Fatalf("expected scope_type=all, got %s", data.DataScopes[0].ScopeType) + } +} diff --git a/tests/integration/admin/plan_test.go b/tests/integration/admin/plan_test.go new file mode 100644 index 0000000..0c83c0c --- /dev/null +++ b/tests/integration/admin/plan_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestPlanList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/plans", map[string]string{ + "page": "1", + "page_size": "20", + }) + testinfra.AssertPaginatedList(t, resp, 0) + + t.Logf("Plan list: total=%d", resp.GetTotal(t)) + + // Filter by active status + respActive := client.Get("/api/admin/plans", map[string]string{ + "page": "1", + "page_size": "20", + "status": "active", + }) + testinfra.AssertPaginatedList(t, respActive, 0) + + t.Logf("Active plan list: total=%d", respActive.GetTotal(t)) +} + +func TestPlanCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create + planID, cleanup := testinfra.CreateTestPlan(t, client) + defer cleanup() + + t.Logf("Created plan: id=%d", planID) + + // Read + detailResp := client.Get(fmt.Sprintf("/api/admin/plans/%d", planID), nil) + detailResp.AssertSuccess(t) + + var detail struct { + Data struct { + Id int64 `json:"id"` + Name string `json:"name"` + Identifier string `json:"identifier"` + Status string `json:"status"` + } `json:"data"` + } + detailResp.DecodeData(t, &detail) + + if detail.Data.Id != planID { + t.Fatalf("expected plan id=%d, got %d", planID, detail.Data.Id) + } + + t.Logf("Plan detail: name=%s, identifier=%s, status=%s", + detail.Data.Name, detail.Data.Identifier, detail.Data.Status) + + // Update + updateResp := client.Put(fmt.Sprintf("/api/admin/plans/%d", planID), map[string]any{ + "update": map[string]any{ + "description": "updated by integration test", + }, + }) + updateResp.AssertSuccess(t) + + t.Logf("Plan updated successfully") + + // Verify update + verifyResp := client.Get(fmt.Sprintf("/api/admin/plans/%d", planID), nil) + verifyResp.AssertSuccess(t) + + var verifyData struct { + Data struct { + Description string `json:"description"` + } `json:"data"` + } + verifyResp.DecodeData(t, &verifyData) + + if verifyData.Data.Description != "updated by integration test" { + t.Fatalf("expected description 'updated by integration test', got %q", + verifyData.Data.Description) + } + + t.Logf("Plan update verified") +} + +func TestPlanToggleRecommend(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + planID, cleanup := testinfra.CreateTestPlan(t, client) + defer cleanup() + + // Toggle recommend on + resp := client.Put(fmt.Sprintf("/api/admin/plans/%d/toggle-recommend", planID), nil) + resp.AssertSuccess(t) + + // Verify toggle + detailResp := client.Get(fmt.Sprintf("/api/admin/plans/%d", planID), nil) + detailResp.AssertSuccess(t) + + var detail struct { + Data struct { + IsRecommended bool `json:"is_recommended"` + } `json:"data"` + } + detailResp.DecodeData(t, &detail) + + t.Logf("Plan %d is_recommended=%v after first toggle", planID, detail.Data.IsRecommended) + + // Toggle again to revert + resp2 := client.Put(fmt.Sprintf("/api/admin/plans/%d/toggle-recommend", planID), nil) + resp2.AssertSuccess(t) + + t.Logf("Plan toggle-recommend completed successfully") +} diff --git a/tests/integration/admin/plugin_test.go b/tests/integration/admin/plugin_test.go new file mode 100644 index 0000000..db516f4 --- /dev/null +++ b/tests/integration/admin/plugin_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestPluginList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/plugins", nil) + resp.AssertSuccess(t) + + var data struct { + List []struct { + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description"` + Version string `json:"version"` + Category string `json:"category"` + Author string `json:"author"` + Status string `json:"status"` + Installed bool `json:"installed"` + Config any `json:"config"` + } `json:"list"` + } + resp.DecodeData(t, &data) + + t.Logf("Plugin list returned %d entries", len(data.List)) +} + +func TestPluginListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by category + resp := client.Get("/api/admin/plugins", map[string]string{ + "category": "payment", + }) + resp.AssertSuccess(t) + + // Filter by status + resp = client.Get("/api/admin/plugins", map[string]string{ + "status": "installed", + }) + resp.AssertSuccess(t) + + t.Logf("Plugin list filters applied successfully") +} diff --git a/tests/integration/admin/promo_test.go b/tests/integration/admin/promo_test.go new file mode 100644 index 0000000..66c0bcd --- /dev/null +++ b/tests/integration/admin/promo_test.go @@ -0,0 +1,19 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestPromoCodeList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/promo-codes", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} diff --git a/tests/integration/admin/redemption_test.go b/tests/integration/admin/redemption_test.go new file mode 100644 index 0000000..ec9ffdc --- /dev/null +++ b/tests/integration/admin/redemption_test.go @@ -0,0 +1,41 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestRedemptionList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/redemptions", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestRedemptionListWithStatusFilter(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by status + resp := client.Get("/api/admin/redemptions", map[string]string{ + "page": "1", + "page_size": "10", + "status": "active", + }) + resp.AssertSuccess(t) + + // Filter by unused status + resp = client.Get("/api/admin/redemptions", map[string]string{ + "page": "1", + "page_size": "10", + "status": "used", + }) + resp.AssertSuccess(t) + + t.Logf("Redemption list filters applied successfully") +} diff --git a/tests/integration/admin/security_test.go b/tests/integration/admin/security_test.go new file mode 100644 index 0000000..1011bc9 --- /dev/null +++ b/tests/integration/admin/security_test.go @@ -0,0 +1,87 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestLoginHistory(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/security/login-history", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestLoginHistoryWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by success status + resp := client.Get("/api/admin/security/login-history", map[string]string{ + "page": "1", + "page_size": "10", + "success": "true", + }) + resp.AssertSuccess(t) + + // Filter by login method + resp = client.Get("/api/admin/security/login-history", map[string]string{ + "page": "1", + "page_size": "10", + "login_method": "password", + }) + resp.AssertSuccess(t) + + // Filter by username + resp = client.Get("/api/admin/security/login-history", map[string]string{ + "page": "1", + "page_size": "10", + "username": "admin", + }) + resp.AssertSuccess(t) + + t.Logf("Login history filters applied successfully") +} + +func TestTenantLoginHistory(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create a tenant to filter by + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp := client.Get("/api/admin/security/tenant-login-history", map[string]string{ + "page": "1", + "page_size": "10", + "tenant_id": fmt.Sprintf("%d", tenantID), + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestTenantLoginHistoryWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by IP address + resp := client.Get("/api/admin/security/tenant-login-history", map[string]string{ + "page": "1", + "page_size": "10", + "ip_address": "127.0.0.1", + }) + resp.AssertSuccess(t) + + // Filter by success + resp = client.Get("/api/admin/security/tenant-login-history", map[string]string{ + "page": "1", + "page_size": "10", + "success": "false", + }) + resp.AssertSuccess(t) + + t.Logf("Tenant login history filters applied successfully") +} diff --git a/tests/integration/admin/settings_test.go b/tests/integration/admin/settings_test.go new file mode 100644 index 0000000..0c38af5 --- /dev/null +++ b/tests/integration/admin/settings_test.go @@ -0,0 +1,133 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestSettingsCategories(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/settings/categories", nil) + resp.AssertSuccess(t) + + var data struct { + List []struct { + Key string `json:"key"` + Label string `json:"label"` + Icon string `json:"icon"` + Order int `json:"order"` + } `json:"list"` + } + resp.DecodeData(t, &data) + + if len(data.List) == 0 { + t.Fatal("expected at least one settings category") + } + + for _, cat := range data.List { + if cat.Key == "" { + t.Fatal("settings category key should not be empty") + } + if cat.Label == "" { + t.Fatalf("category %s has empty label", cat.Key) + } + } + + t.Logf("Settings categories returned %d entries", len(data.List)) +} + +func TestSettingsGet(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // First get categories to find a valid one + catResp := client.Get("/api/admin/settings/categories", nil) + catResp.AssertSuccess(t) + + var catData struct { + List []struct { + Key string `json:"key"` + } `json:"list"` + } + catResp.DecodeData(t, &catData) + + if len(catData.List) == 0 { + t.Skip("No settings categories found, skipping get test") + } + + // Get settings for the first category + category := catData.List[0].Key + resp := client.Get(fmt.Sprintf("/api/admin/settings/%s", category), nil) + resp.AssertSuccess(t) + + var data struct { + List []struct { + Key string `json:"key"` + Value any `json:"value"` + Type string `json:"type"` + Label string `json:"label"` + Description string `json:"description"` + Sensitive bool `json:"sensitive"` + Validation string `json:"validation"` + Default any `json:"default"` + } `json:"list"` + } + resp.DecodeData(t, &data) + + t.Logf("Settings for category %q returned %d entries", category, len(data.List)) +} + +func TestSettingsUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // First get categories to find a valid one + catResp := client.Get("/api/admin/settings/categories", nil) + catResp.AssertSuccess(t) + + var catData struct { + List []struct { + Key string `json:"key"` + } `json:"list"` + } + catResp.DecodeData(t, &catData) + + if len(catData.List) == 0 { + t.Skip("No settings categories found, skipping update test") + } + + category := catData.List[0].Key + + // Get current settings + getResp := client.Get(fmt.Sprintf("/api/admin/settings/%s", category), nil) + getResp.AssertSuccess(t) + + var getData struct { + List []struct { + Key string `json:"key"` + Value any `json:"value"` + } `json:"list"` + } + getResp.DecodeData(t, &getData) + + if len(getData.List) == 0 { + t.Skipf("No settings found in category %q, skipping update test", category) + } + + // Build settings map from current values (non-destructive) + settings := make(map[string]any) + for _, item := range getData.List { + settings[item.Key] = item.Value + } + + // Update with same values + updateResp := client.Put(fmt.Sprintf("/api/admin/settings/%s", category), map[string]any{ + "settings": settings, + }) + updateResp.AssertSuccess(t) + + t.Logf("Settings update for category %q succeeded (%d keys)", category, len(settings)) +} diff --git a/tests/integration/admin/task_test.go b/tests/integration/admin/task_test.go new file mode 100644 index 0000000..45b2bc1 --- /dev/null +++ b/tests/integration/admin/task_test.go @@ -0,0 +1,49 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestTaskList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/tasks", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestTaskListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by status + resp := client.Get("/api/admin/tasks", map[string]string{ + "page": "1", + "page_size": "10", + "status": "completed", + }) + resp.AssertSuccess(t) + + // Filter by platform + resp = client.Get("/api/admin/tasks", map[string]string{ + "page": "1", + "page_size": "10", + "platform": "suno", + }) + resp.AssertSuccess(t) + + // Filter by public_task_id + resp = client.Get("/api/admin/tasks", map[string]string{ + "page": "1", + "page_size": "10", + "public_task_id": "nonexistent", + }) + resp.AssertSuccess(t) + + t.Logf("Task list filters applied successfully") +} diff --git a/tests/integration/admin/tenant_level_test.go b/tests/integration/admin/tenant_level_test.go new file mode 100644 index 0000000..a355fc1 --- /dev/null +++ b/tests/integration/admin/tenant_level_test.go @@ -0,0 +1,143 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestTenantLevelList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create a level to ensure at least one exists + _, cleanup := testinfra.CreateTestTenantLevel(t, client) + defer cleanup() + + resp := client.Get("/api/admin/tenant-level-configs", nil) + resp.AssertSuccess(t) + + var result struct { + List []struct { + ID int64 `json:"id"` + Level int `json:"level"` + Name string `json:"name"` + CumulativeRechargeThreshold float64 `json:"cumulative_recharge_threshold"` + MaxMembers int `json:"max_members"` + MaxConcurrency int `json:"max_concurrency"` + PriceMultiplier float64 `json:"price_multiplier"` + SortOrder int `json:"sort_order"` + } `json:"list"` + } + resp.DecodeData(t, &result) + if len(result.List) < 1 { + t.Fatalf("expected at least 1 tenant level config, got %d", len(result.List)) + } +} + +func TestTenantLevelCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // --- Create --- + suffix := randomSuffix() + createResp := client.Post("/api/admin/tenant-level-configs", map[string]any{ + "level": 80 + int(suffix[0]%10), + "name": fmt.Sprintf("CRUD测试等级 %s", suffix), + "cumulative_recharge_threshold": 200.0, + "max_members": 100, + "max_concurrency": 20, + "price_multiplier": 0.85, + "sort_order": 10, + }) + createResp.AssertSuccess(t) + levelID := createResp.GetID(t) + + // --- List should contain the created level --- + listResp := client.Get("/api/admin/tenant-level-configs", nil) + listResp.AssertSuccess(t) + + var listResult struct { + List []struct { + ID int64 `json:"id"` + Name string `json:"name"` + MaxMembers int `json:"max_members"` + MaxConcurrency int `json:"max_concurrency"` + PriceMultiplier float64 `json:"price_multiplier"` + } `json:"list"` + } + listResp.DecodeData(t, &listResult) + + found := false + for _, item := range listResult.List { + if item.ID == levelID { + found = true + if item.MaxMembers != 100 { + t.Fatalf("expected max_members=100, got %d", item.MaxMembers) + } + if item.MaxConcurrency != 20 { + t.Fatalf("expected max_concurrency=20, got %d", item.MaxConcurrency) + } + break + } + } + if !found { + t.Fatalf("created level id=%d not found in list", levelID) + } + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/tenant-level-configs/%d", levelID), map[string]any{ + "name": fmt.Sprintf("更新等级名 %s", suffix), + "max_members": 200, + "max_concurrency": 50, + "price_multiplier": 0.80, + }) + updateResp.AssertSuccess(t) + + // Verify update via list + verifyResp := client.Get("/api/admin/tenant-level-configs", nil) + verifyResp.AssertSuccess(t) + + var verifyResult struct { + List []struct { + ID int64 `json:"id"` + MaxMembers int `json:"max_members"` + MaxConcurrency int `json:"max_concurrency"` + PriceMultiplier float64 `json:"price_multiplier"` + } `json:"list"` + } + verifyResp.DecodeData(t, &verifyResult) + + for _, item := range verifyResult.List { + if item.ID == levelID { + if item.MaxMembers != 200 { + t.Fatalf("expected max_members=200 after update, got %d", item.MaxMembers) + } + if item.MaxConcurrency != 50 { + t.Fatalf("expected max_concurrency=50 after update, got %d", item.MaxConcurrency) + } + break + } + } + + // --- Delete --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/tenant-level-configs/%d", levelID)) + deleteResp.AssertSuccess(t) + + // Verify deletion + afterDeleteResp := client.Get("/api/admin/tenant-level-configs", nil) + afterDeleteResp.AssertSuccess(t) + + var afterDeleteResult struct { + List []struct { + ID int64 `json:"id"` + } `json:"list"` + } + afterDeleteResp.DecodeData(t, &afterDeleteResult) + for _, item := range afterDeleteResult.List { + if item.ID == levelID { + t.Fatalf("level id=%d should have been deleted", levelID) + } + } +} diff --git a/tests/integration/admin/tenant_model_test.go b/tests/integration/admin/tenant_model_test.go new file mode 100644 index 0000000..b3105ba --- /dev/null +++ b/tests/integration/admin/tenant_model_test.go @@ -0,0 +1,216 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestTenantModelAssign(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, tenantCleanup := testinfra.CreateTestTenant(t, client) + defer tenantCleanup() + + model1ID, model1Cleanup := testinfra.CreateTestModel(t, client) + defer model1Cleanup() + + model2ID, model2Cleanup := testinfra.CreateTestModel(t, client) + defer model2Cleanup() + + // --- Assign models to tenant --- + assignResp := client.Post(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), map[string]any{ + "assignments": []map[string]any{ + { + "model_id": model1ID, + "enabled": true, + "billing_mode": "token", + }, + { + "model_id": model2ID, + "enabled": true, + "billing_mode": "token", + }, + }, + }) + assignResp.AssertSuccess(t) + + var assignResult struct { + Assigned int `json:"assigned"` + } + assignResp.DecodeData(t, &assignResult) + if assignResult.Assigned != 2 { + t.Fatalf("expected assigned=2, got %d", assignResult.Assigned) + } + + // --- Verify via list --- + listResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), nil) + listResp.AssertSuccess(t) + + var listResult struct { + List []struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + ModelID int64 `json:"model_id"` + ModelCode string `json:"model_code"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Enabled bool `json:"enabled"` + } `json:"list"` + } + listResp.DecodeData(t, &listResult) + if len(listResult.List) < 2 { + t.Fatalf("expected at least 2 tenant models, got %d", len(listResult.List)) + } +} + +func TestTenantModelList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, tenantCleanup := testinfra.CreateTestTenant(t, client) + defer tenantCleanup() + + modelID, modelCleanup := testinfra.CreateTestModel(t, client) + defer modelCleanup() + + // Assign a model first + assignResp := client.Post(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), map[string]any{ + "assignments": []map[string]any{ + { + "model_id": modelID, + "enabled": true, + "billing_mode": "token", + }, + }, + }) + assignResp.AssertSuccess(t) + + // List tenant models + listResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), nil) + listResp.AssertSuccess(t) + + var result struct { + List []struct { + ModelID int64 `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Enabled bool `json:"enabled"` + } `json:"list"` + } + listResp.DecodeData(t, &result) + + found := false + for _, m := range result.List { + if m.ModelID == modelID { + found = true + if !m.Enabled { + t.Fatal("expected model to be enabled") + } + break + } + } + if !found { + t.Fatalf("assigned model id=%d not found in tenant model list", modelID) + } +} + +func TestTenantModelUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, tenantCleanup := testinfra.CreateTestTenant(t, client) + defer tenantCleanup() + + modelID, modelCleanup := testinfra.CreateTestModel(t, client) + defer modelCleanup() + + // Assign model + assignResp := client.Post(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), map[string]any{ + "assignments": []map[string]any{ + { + "model_id": modelID, + "enabled": true, + "billing_mode": "token", + }, + }, + }) + assignResp.AssertSuccess(t) + + // --- Update tenant model --- + updateResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d/models/%d", tenantID, modelID), map[string]any{ + "enabled": false, + "billing_mode": "per_request", + }) + updateResp.AssertSuccess(t) + + // Verify update + listResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), nil) + listResp.AssertSuccess(t) + + var result struct { + List []struct { + ModelID int64 `json:"model_id"` + Enabled bool `json:"enabled"` + BillingMode string `json:"billing_mode"` + } `json:"list"` + } + listResp.DecodeData(t, &result) + + for _, m := range result.List { + if m.ModelID == modelID { + if m.Enabled { + t.Fatal("expected model to be disabled after update") + } + if m.BillingMode != "per_request" { + t.Fatalf("expected billing_mode=per_request, got %s", m.BillingMode) + } + return + } + } + t.Fatalf("model id=%d not found after update", modelID) +} + +func TestTenantModelDelete(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, tenantCleanup := testinfra.CreateTestTenant(t, client) + defer tenantCleanup() + + modelID, modelCleanup := testinfra.CreateTestModel(t, client) + defer modelCleanup() + + // Assign model + assignResp := client.Post(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), map[string]any{ + "assignments": []map[string]any{ + { + "model_id": modelID, + "enabled": true, + "billing_mode": "token", + }, + }, + }) + assignResp.AssertSuccess(t) + + // --- Delete tenant model --- + deleteResp := client.Delete(fmt.Sprintf("/api/admin/tenants/%d/models/%d", tenantID, modelID)) + deleteResp.AssertSuccess(t) + + // Verify deletion + listResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d/models", tenantID), nil) + listResp.AssertSuccess(t) + + var result struct { + List []struct { + ModelID int64 `json:"model_id"` + } `json:"list"` + } + listResp.DecodeData(t, &result) + + for _, m := range result.List { + if m.ModelID == modelID { + t.Fatalf("model id=%d should have been deleted from tenant", modelID) + } + } +} diff --git a/tests/integration/admin/tenant_test.go b/tests/integration/admin/tenant_test.go new file mode 100644 index 0000000..2476c31 --- /dev/null +++ b/tests/integration/admin/tenant_test.go @@ -0,0 +1,238 @@ +//go:build integration + +package admin_test + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func randomSuffix() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func TestTenantList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Create a tenant to ensure at least one exists + _, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp := client.Get("/api/admin/tenants", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 1) +} + +func TestTenantListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + // Filter by status + resp := client.Get("/api/admin/tenants", map[string]string{ + "page": "1", + "page_size": "10", + "status": "active", + }) + resp.AssertSuccess(t) + + // Filter by keyword + resp = client.Get("/api/admin/tenants", map[string]string{ + "page": "1", + "page_size": "10", + "keyword": "test-tenant", + }) + resp.AssertSuccess(t) +} + +func TestTenantCRUD(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // --- Create --- + suffix := randomSuffix() + createResp := client.Post("/api/admin/tenants", map[string]any{ + "tenant_name": fmt.Sprintf("CRUD测试租户 %s", suffix), + "tenant_code": fmt.Sprintf("crud-tenant-%s", suffix), + "username": fmt.Sprintf("crudowner%s", suffix), + "email": fmt.Sprintf("crudowner-%s@test.com", suffix), + "password": "OwnerPass123!", + "max_members": 20, + "max_concurrency": 5, + }) + createResp.AssertSuccess(t) + tenantID := createResp.GetID(t) + defer func() { + client.Put(fmt.Sprintf("/api/admin/tenants/%d/status", tenantID), map[string]any{ + "status": "closed", + }) + }() + + // --- Get detail --- + detailResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + detailResp.AssertSuccess(t) + + var detail struct { + ID int64 `json:"id"` + TenantName string `json:"tenant_name"` + TenantCode string `json:"tenant_code"` + MaxMembers int `json:"max_members"` + MaxConcurrency int `json:"max_concurrency"` + } + detailResp.DecodeData(t, &detail) + + if detail.ID != tenantID { + t.Fatalf("expected tenant id %d, got %d", tenantID, detail.ID) + } + if detail.MaxMembers != 20 { + t.Fatalf("expected max_members=20, got %d", detail.MaxMembers) + } + + // --- Update --- + updateResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "name": fmt.Sprintf("更新租户名 %s", suffix), + "max_members": 50, + "max_concurrency": 10, + }) + updateResp.AssertSuccess(t) + + // Verify update + verifyResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + verifyResp.AssertSuccess(t) + var updated struct { + MaxMembers int `json:"max_members"` + MaxConcurrency int `json:"max_concurrency"` + } + verifyResp.DecodeData(t, &updated) + if updated.MaxMembers != 50 { + t.Fatalf("expected max_members=50 after update, got %d", updated.MaxMembers) + } + + // --- List should contain the created tenant --- + listResp := client.Get("/api/admin/tenants", map[string]string{ + "page": "1", + "page_size": "50", + }) + testinfra.AssertPaginatedList(t, listResp, 1) + total := listResp.GetTotal(t) + if total < 1 { + t.Fatalf("expected at least 1 tenant in list, got total=%d", total) + } +} + +func TestTenantStatusUpdate(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + // Suspend the tenant + suspendResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d/status", tenantID), map[string]any{ + "status": "suspended", + }) + suspendResp.AssertSuccess(t) + + // Verify status + detailResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + detailResp.AssertSuccess(t) + var detail struct { + Status string `json:"status"` + } + detailResp.DecodeData(t, &detail) + if detail.Status != "suspended" { + t.Fatalf("expected status=suspended, got %s", detail.Status) + } + + // Reactivate + activateResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d/status", tenantID), map[string]any{ + "status": "active", + }) + activateResp.AssertSuccess(t) + + // Verify reactivation + detailResp2 := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + detailResp2.AssertSuccess(t) + var detail2 struct { + Status string `json:"status"` + } + detailResp2.DecodeData(t, &detail2) + if detail2.Status != "active" { + t.Fatalf("expected status=active after reactivation, got %s", detail2.Status) + } +} + +func TestTenantChannelScope(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + // Update channel scope + scopeResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d/channel-scope", tenantID), map[string]any{ + "default_channel_scope": "all", + }) + scopeResp.AssertSuccess(t) +} + +func TestTenantSelect(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp := client.Get("/api/admin/tenants/select", map[string]string{ + "page": "1", + "page_size": "10", + }) + resp.AssertSuccess(t) + + var result struct { + List []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + } `json:"list"` + Total int `json:"total"` + } + resp.DecodeData(t, &result) + if result.Total < 1 { + t.Fatalf("expected at least 1 tenant in select, got total=%d", result.Total) + } + + // Filter by keyword + resp2 := client.Get("/api/admin/tenants/select", map[string]string{ + "keyword": "test-tenant", + "page": "1", + "page_size": "10", + }) + resp2.AssertSuccess(t) +} + +func TestMemberList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + _, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp := client.Get("/api/admin/members", map[string]string{ + "page": "1", + "page_size": "10", + }) + resp.AssertSuccess(t) + + // List with filters + resp = client.Get("/api/admin/members", map[string]string{ + "page": "1", + "page_size": "10", + "status": "active", + }) + resp.AssertSuccess(t) +} diff --git a/tests/integration/admin/testinfra/assertions.go b/tests/integration/admin/testinfra/assertions.go new file mode 100644 index 0000000..2b13f51 --- /dev/null +++ b/tests/integration/admin/testinfra/assertions.go @@ -0,0 +1,45 @@ +//go:build integration + +package testinfra + +import ( + "encoding/json" + "testing" +) + +func AssertPaginatedList(t *testing.T, resp *APIResponse, minCount int) { + t.Helper() + resp.AssertSuccess(t) + + var result struct { + List json.RawMessage `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + resp.DecodeData(t, &result) + + if result.Total < minCount { + t.Fatalf("expected total >= %d, got %d", minCount, result.Total) + } + if result.Page < 1 { + t.Fatalf("expected page >= 1, got %d", result.Page) + } + if result.PageSize < 1 { + t.Fatalf("expected page_size >= 1, got %d", result.PageSize) + } +} + +func AssertNonEmptyList(t *testing.T, resp *APIResponse) { + t.Helper() + resp.AssertSuccess(t) + + var result struct { + List json.RawMessage `json:"list"` + } + resp.DecodeData(t, &result) + + if string(result.List) == "null" || string(result.List) == "[]" { + t.Fatal("expected non-empty list") + } +} diff --git a/tests/integration/admin/testinfra/auth.go b/tests/integration/admin/testinfra/auth.go new file mode 100644 index 0000000..9fc3fbb --- /dev/null +++ b/tests/integration/admin/testinfra/auth.go @@ -0,0 +1,94 @@ +//go:build integration + +package testinfra + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/redis/go-redis/v9" +) + +func getRedisClient() *redis.Client { + return redis.NewClient(&redis.Options{ + Addr: DefaultRedisAddr, + DB: 0, + DialTimeout: 3 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }) +} + +type LoginResult struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt string `json:"expires_at"` + TotpRequired bool `json:"totp_required"` +} + +func Authenticate(t *testing.T) *LoginResult { + t.Helper() + return AuthenticateWithCreds(t, DefaultUsername, DefaultPassword) +} + +func AuthenticateWithCreds(t *testing.T, username, password string) *LoginResult { + t.Helper() + client := NewAPIClient(DefaultBaseURL) + + // Step 1: Get captcha + captchaResp := client.Get("/api/captcha/", nil) + captchaResp.AssertSuccess(t) + + var captchaData struct { + CaptchaKey string `json:"captcha_key"` + } + captchaResp.DecodeData(t, &captchaData) + + // Step 2: Read answer from Redis + rdb := getRedisClient() + defer rdb.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + redisKey := fmt.Sprintf("captcha:state:%s", captchaData.CaptchaKey) + val, err := rdb.Get(ctx, redisKey).Result() + if err != nil { + t.Fatalf("read captcha from redis error: %v", err) + } + + var answer struct { + X int `json:"x"` + Y int `json:"y"` + } + if err := json.Unmarshal([]byte(val), &answer); err != nil { + t.Fatalf("unmarshal captcha answer error: %v", err) + } + + // Step 3: Login with the answer + loginResp := client.Post("/api/admin/auth/login", map[string]any{ + "username": username, + "password": password, + "captcha_key": captchaData.CaptchaKey, + "captcha_x": answer.X, + }) + loginResp.AssertSuccess(t) + + var loginResult LoginResult + loginResp.DecodeData(t, &loginResult) + + if loginResult.TotpRequired { + t.Fatal("admin user has 2FA enabled, cannot auto-login for tests") + } + + return &loginResult +} + +func GetAuthedClient(t *testing.T) *APIClient { + t.Helper() + result := Authenticate(t) + return NewAPIClient(DefaultBaseURL).WithToken(result.AccessToken) +} diff --git a/tests/integration/admin/testinfra/client.go b/tests/integration/admin/testinfra/client.go new file mode 100644 index 0000000..414adde --- /dev/null +++ b/tests/integration/admin/testinfra/client.go @@ -0,0 +1,158 @@ +//go:build integration + +package testinfra + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +type APIResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + RequestID string `json:"request_id"` + HTTPStatus int +} + +type APIClient struct { + BaseURL string + HTTPClient *http.Client + Token string +} + +func NewAPIClient(baseURL string) *APIClient { + return &APIClient{ + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *APIClient) WithToken(token string) *APIClient { + clone := *c + clone.Token = token + return &clone +} + +func (c *APIClient) Get(path string, params map[string]string) *APIResponse { + return c.doRequest("GET", path, params, nil) +} + +func (c *APIClient) Post(path string, body any) *APIResponse { + return c.doRequest("POST", path, nil, body) +} + +func (c *APIClient) Put(path string, body any) *APIResponse { + return c.doRequest("PUT", path, nil, body) +} + +func (c *APIClient) Delete(path string) *APIResponse { + return c.doRequest("DELETE", path, nil, nil) +} + +func (c *APIClient) doRequest(method, path string, params map[string]string, body any) *APIResponse { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return &APIResponse{HTTPStatus: 0, Message: fmt.Sprintf("marshal error: %v", err)} + } + bodyReader = bytes.NewReader(b) + } + + fullURL := c.BaseURL + path + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + if strings.Contains(fullURL, "?") { + fullURL += "&" + q.Encode() + } else { + fullURL += "?" + q.Encode() + } + } + + req, err := http.NewRequest(method, fullURL, bodyReader) + if err != nil { + return &APIResponse{HTTPStatus: 0, Message: fmt.Sprintf("create request error: %v", err)} + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return &APIResponse{HTTPStatus: 0, Message: fmt.Sprintf("request error: %v", err)} + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return &APIResponse{HTTPStatus: resp.StatusCode, Message: fmt.Sprintf("read body error: %v", err)} + } + + apiResp := &APIResponse{HTTPStatus: resp.StatusCode} + _ = json.Unmarshal(data, apiResp) + return apiResp +} + +func (r *APIResponse) AssertSuccess(t *testing.T) { + t.Helper() + if r.HTTPStatus == 0 { + t.Fatalf("request failed: %s", r.Message) + } + if r.Code != 0 { + t.Fatalf("expected code=0, got code=%d message=%q request_id=%s", r.Code, r.Message, r.RequestID) + } +} + +func (r *APIResponse) AssertError(t *testing.T, expectedCode int) { + t.Helper() + if r.Code != expectedCode { + t.Fatalf("expected code=%d, got code=%d message=%q", expectedCode, r.Code, r.Message) + } +} + +func (r *APIResponse) AssertHTTPStatus(t *testing.T, expected int) { + t.Helper() + if r.HTTPStatus != expected { + t.Fatalf("expected HTTP %d, got %d", expected, r.HTTPStatus) + } +} + +func (r *APIResponse) DecodeData(t *testing.T, target any) { + t.Helper() + if err := json.Unmarshal(r.Data, target); err != nil { + t.Fatalf("decode data error: %v, raw: %s", err, string(r.Data)) + } +} + +func (r *APIResponse) GetID(t *testing.T) int64 { + t.Helper() + var result struct { + ID int64 `json:"id"` + } + r.DecodeData(t, &result) + return result.ID +} + +func (r *APIResponse) GetTotal(t *testing.T) int { + t.Helper() + var result struct { + Total int `json:"total"` + } + r.DecodeData(t, &result) + return result.Total +} diff --git a/tests/integration/admin/testinfra/config.go b/tests/integration/admin/testinfra/config.go new file mode 100644 index 0000000..e6080f1 --- /dev/null +++ b/tests/integration/admin/testinfra/config.go @@ -0,0 +1,10 @@ +//go:build integration + +package testinfra + +const ( + DefaultBaseURL = "http://127.0.0.1:18888" + DefaultUsername = "admin" + DefaultPassword = "Admin123" + DefaultRedisAddr = "192.168.50.22:16380" +) diff --git a/tests/integration/admin/testinfra/fixture.go b/tests/integration/admin/testinfra/fixture.go new file mode 100644 index 0000000..de72b6d --- /dev/null +++ b/tests/integration/admin/testinfra/fixture.go @@ -0,0 +1,127 @@ +//go:build integration + +package testinfra + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "testing" +) + +func randomSuffix() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func CreateTestAdminUser(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + username := fmt.Sprintf("testuser%s", randomSuffix()) + resp := client.Post("/api/admin/users", map[string]any{ + "username": username, + "password": "TestPass123!", + "email": fmt.Sprintf("%s@test.com", username), + "role": "admin", + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Delete(fmt.Sprintf("/api/admin/users/%d", id)) + } +} + +func CreateTestTenant(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + code := fmt.Sprintf("test-tenant-%s", randomSuffix()) + resp := client.Post("/api/admin/tenants", map[string]any{ + "tenant_name": fmt.Sprintf("测试租户 %s", code), + "tenant_code": code, + "username": fmt.Sprintf("owner%s", randomSuffix()), + "email": fmt.Sprintf("owner-%s@test.com", code), + "password": "OwnerPass123!", + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Put(fmt.Sprintf("/api/admin/tenants/%d/status", id), map[string]any{ + "status": "closed", + }) + } +} + +func CreateTestModel(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + modelID := fmt.Sprintf("test-model-%s", randomSuffix()) + resp := client.Post("/api/admin/models", map[string]any{ + "model_id": modelID, + "model_name": fmt.Sprintf("Test Model %s", modelID), + "category": "chat", + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Delete(fmt.Sprintf("/api/admin/models/%d", id)) + } +} + +func CreateTestChannel(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + name := fmt.Sprintf("Test Channel %s", randomSuffix()) + resp := client.Post("/api/admin/channels", map[string]any{ + "name": name, + "type": 1, + "api_key": "sk-test-key-" + randomSuffix(), + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Delete(fmt.Sprintf("/api/admin/channels/%d", id)) + } +} + +func CreateTestPlan(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + ident := fmt.Sprintf("test-plan-%s", randomSuffix()) + resp := client.Post("/api/admin/plans", map[string]any{ + "name": fmt.Sprintf("Test Plan %s", ident), + "identifier": ident, + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Delete(fmt.Sprintf("/api/admin/plans/%d", id)) + } +} + +func CreateTestModelGroup(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + code := fmt.Sprintf("test-group-%s", randomSuffix()) + resp := client.Post("/api/admin/model-groups", map[string]any{ + "name": fmt.Sprintf("Test Group %s", code), + "code": code, + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Delete(fmt.Sprintf("/api/admin/model-groups/%d", id)) + } +} + +func CreateTestTenantLevel(t *testing.T, client *APIClient) (id int64, cleanup func()) { + t.Helper() + suffix := randomSuffix() + resp := client.Post("/api/admin/tenant-level-configs", map[string]any{ + "level": 90 + int(suffix[0]%10), + "name": fmt.Sprintf("测试等级 %s", suffix), + "cumulative_recharge_threshold": 100.0, + "max_members": 50, + "max_concurrency": 10, + "price_multiplier": 0.9, + }) + resp.AssertSuccess(t) + id = resp.GetID(t) + return id, func() { + client.Delete(fmt.Sprintf("/api/admin/tenant-level-configs/%d", id)) + } +} diff --git a/tests/integration/admin/ticket_test.go b/tests/integration/admin/ticket_test.go new file mode 100644 index 0000000..be821c5 --- /dev/null +++ b/tests/integration/admin/ticket_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestTicketList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/tickets", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) +} + +func TestTicketListWithFilters(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Filter by status + resp := client.Get("/api/admin/tickets", map[string]string{ + "page": "1", + "page_size": "10", + "status": "open", + }) + resp.AssertSuccess(t) + + // Filter by category + resp = client.Get("/api/admin/tickets", map[string]string{ + "page": "1", + "page_size": "10", + "category": "billing", + }) + resp.AssertSuccess(t) + + // Create tenant and filter by tenant_id + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp = client.Get("/api/admin/tickets", map[string]string{ + "page": "1", + "page_size": "10", + "tenant_id": fmt.Sprintf("%d", tenantID), + }) + resp.AssertSuccess(t) + + t.Logf("Ticket list filters applied successfully") +} diff --git a/tests/integration/admin/usage_test.go b/tests/integration/admin/usage_test.go new file mode 100644 index 0000000..6360275 --- /dev/null +++ b/tests/integration/admin/usage_test.go @@ -0,0 +1,41 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestUsageLogList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // Basic list with default pagination + resp := client.Get("/api/admin/usage-logs", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) + + t.Logf("Usage log list: total=%d", resp.GetTotal(t)) + + // List with date filters + respFiltered := client.Get("/api/admin/usage-logs", map[string]string{ + "page": "1", + "page_size": "5", + "start_date": "2025-01-01", + "end_date": "2030-12-31", + }) + testinfra.AssertPaginatedList(t, respFiltered, 0) + + // List with status filter + respByStatus := client.Get("/api/admin/usage-logs", map[string]string{ + "page": "1", + "page_size": "5", + "status": "success", + }) + testinfra.AssertPaginatedList(t, respByStatus, 0) + + t.Logf("Usage log list with filters returned successfully") +} diff --git a/tests/integration/admin/wallet_test.go b/tests/integration/admin/wallet_test.go new file mode 100644 index 0000000..25fa1ec --- /dev/null +++ b/tests/integration/admin/wallet_test.go @@ -0,0 +1,131 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +func TestWalletList(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + resp := client.Get("/api/admin/wallets", map[string]string{ + "page": "1", + "page_size": "10", + }) + testinfra.AssertPaginatedList(t, resp, 0) + + t.Logf("Wallet list retrieved successfully") +} + +func TestWalletInfo(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + resp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil) + resp.AssertSuccess(t) + + var data struct { + Balance float64 `json:"balance"` + FrozenBalance float64 `json:"frozen_balance"` + WarningThreshold *float64 `json:"warning_threshold"` + } + resp.DecodeData(t, &data) + + if data.Balance < 0 { + t.Fatalf("expected balance >= 0, got %f", data.Balance) + } + if data.FrozenBalance < 0 { + t.Fatalf("expected frozen_balance >= 0, got %f", data.FrozenBalance) + } + + t.Logf("Wallet info for tenant %d: balance=%f, frozen=%f", + tenantID, data.Balance, data.FrozenBalance) +} + +func TestWalletAdjust(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + // Add balance + resp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{ + "amount": 10.5, + "description": "integration test adjustment", + }) + resp.AssertSuccess(t) + + // Verify balance increased + infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil) + infoResp.AssertSuccess(t) + + var data struct { + Balance float64 `json:"balance"` + } + infoResp.DecodeData(t, &data) + + if data.Balance < 10.0 { + t.Fatalf("expected balance >= 10.0 after adjustment, got %f", data.Balance) + } + + t.Logf("Wallet adjust succeeded, new balance: %f", data.Balance) +} + +func TestWalletTransactions(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + // First adjust wallet to create a transaction + adjustResp := client.Post(fmt.Sprintf("/api/admin/wallets/%d/adjust", tenantID), map[string]any{ + "amount": 5.0, + "description": "test transaction for listing", + }) + adjustResp.AssertSuccess(t) + + // Then list transactions + resp := client.Get(fmt.Sprintf("/api/admin/wallets/%d/transactions", tenantID), map[string]string{ + "page": "1", + "page_size": "20", + }) + testinfra.AssertPaginatedList(t, resp, 1) + + total := resp.GetTotal(t) + t.Logf("Wallet transactions for tenant %d: total=%d", tenantID, total) +} + +func TestWalletWarningThreshold(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + tenantID, cleanup := testinfra.CreateTestTenant(t, client) + defer cleanup() + + // Set warning threshold + threshold := 1.5 + resp := client.Put(fmt.Sprintf("/api/admin/wallets/%d/warning-threshold", tenantID), map[string]any{ + "threshold": threshold, + }) + resp.AssertSuccess(t) + + // Verify threshold was set + infoResp := client.Get(fmt.Sprintf("/api/admin/wallets/%d", tenantID), nil) + infoResp.AssertSuccess(t) + + var data struct { + WarningThreshold *float64 `json:"warning_threshold"` + } + infoResp.DecodeData(t, &data) + + if data.WarningThreshold == nil { + t.Fatal("expected warning_threshold to be set, got nil") + } + + t.Logf("Warning threshold set to %f for tenant %d", *data.WarningThreshold, tenantID) +}