Files
sub2api/backend/internal/handler/available_channel_handler_test.go
erio 654cfb6480 feat(channels): add "Available Channels" aggregate view
Add a read-only aggregate view per channel: its linked groups and a
deterministic wildcard-free supported-model list with pricing details.

Backend
- service.Channel.SupportedModels(): combine ModelMapping keys with
  same-platform ModelPricing.Models; trailing "*" keys expand via
  pricing prefix match; platforms without a mapping produce no
  entries (intentional "no mapping = not shown" rule).
- Extract splitWildcardSuffix() shared with toModelEntry.
- Build a per-call pricing lookup map (platform+lowerName -> *pricing)
  to avoid O(N*M) scans in SupportedModels.
- ChannelService.ListAvailable() aggregates channels + active groups;
  filters out group IDs no longer active.
- Admin route GET /api/v1/admin/channels/available returns the full
  DTO (id, status, billing_model_source, restrict_models, groups,
  supported_models).
- User route GET /api/v1/channels/available applies three filters:
  Status==active, visible-group intersection, and platform filter
  on supported_models (prevents cross-platform leak when a channel
  links to both a user-accessible group and an inaccessible one on
  another platform). Response is a plain array (matches the
  /groups/available sibling shape). Field whitelist omits
  billing_model_source, restrict_models, ids, status, sort_order.

Frontend
- New /admin/available-channels and /available-channels views backed
  by a shared AvailableChannelsTable component (admin adds status +
  billing-source columns via slots).
- PricingRow extracted to its own SFC; SupportedModelChip references
  shared billing-mode constants in constants/channel.ts.
- Sidebar: new entry above "渠道管理" for admin; matching entry in
  user nav.
- i18n: zh + en coverage for both namespaces.

Tests
- SupportedModels: wildcard-only pricing skipped, prefix-matches-
  nothing, cross-platform bleed, case-insensitive dedup, empty
  platform mapping.
- ListAvailable: nil groupRepo, inactive-group-ID dropped, stable
  case-insensitive name sort.
- User handler: 401 on unauthenticated, visible-group intersection,
  platform filter on supported_models, JSON whitelist.
- Admin handler: full DTO including default BillingModelSource
  fallback.

Refs: issue #1729
2026-04-21 00:27:10 +08:00

122 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestUserAvailableChannel_Unauthenticated401(t *testing.T) {
// 没有 AuthSubject 注入时handler 应返回 401 且不触达 service 依赖。
gin.SetMode(gin.TestMode)
h := &AvailableChannelHandler{} // nil services — 401 路径不会调用它们
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/channels/available", nil)
h.List(c)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestFilterUserVisibleGroups_IntersectionOnly(t *testing.T) {
// 渠道挂在 {g1, g2, g3},用户只允许 {g1, g3} —— 响应必须仅含 g1/g3。
groups := []service.AvailableGroupRef{
{ID: 1, Name: "g1", Platform: "anthropic"},
{ID: 2, Name: "g2", Platform: "anthropic"},
{ID: 3, Name: "g3", Platform: "openai"},
}
allowed := map[int64]struct{}{1: {}, 3: {}}
visible := filterUserVisibleGroups(groups, allowed)
require.Len(t, visible, 2)
ids := []int64{visible[0].ID, visible[1].ID}
require.ElementsMatch(t, []int64{1, 3}, ids)
}
func TestCollectGroupPlatforms_DerivesAllowedSet(t *testing.T) {
groups := []userAvailableGroup{
{ID: 1, Platform: "anthropic"},
{ID: 2, Platform: "openai"},
{ID: 3, Platform: "anthropic"}, // 去重
{ID: 4, Platform: ""}, // 空平台忽略
}
got := collectGroupPlatforms(groups)
require.Len(t, got, 2)
_, hasAnt := got["anthropic"]
_, hasOA := got["openai"]
require.True(t, hasAnt)
require.True(t, hasOA)
}
func TestToUserSupportedModels_FiltersByAllowedPlatforms(t *testing.T) {
// 用户可访问分组只覆盖 anthropicanthropic 平台的模型保留openai 模型被剔除。
src := []service.SupportedModel{
{Name: "claude-sonnet-4-6", Platform: "anthropic", Pricing: nil},
{Name: "gpt-4o", Platform: "openai", Pricing: nil},
}
allowed := map[string]struct{}{"anthropic": {}}
out := toUserSupportedModels(src, allowed)
require.Len(t, out, 1)
require.Equal(t, "claude-sonnet-4-6", out[0].Name)
}
func TestToUserSupportedModels_NilAllowedPlatformsKeepsAll(t *testing.T) {
// 显式传 nil allowedPlatforms 表示不做过滤。
src := []service.SupportedModel{
{Name: "a", Platform: "anthropic"},
{Name: "b", Platform: "openai"},
}
require.Len(t, toUserSupportedModels(src, nil), 2)
}
func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
// 通过序列化 userAvailableChannel 结构体验证响应形状:
// 只有 name / description / groups / supported_models不含管理端字段。
row := userAvailableChannel{
Name: "ch",
Description: "d",
Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}},
SupportedModels: []userSupportedModel{},
}
raw, err := json.Marshal(row)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
for _, key := range []string{"id", "status", "billing_model_source", "restrict_models"} {
_, exists := decoded[key]
require.Falsef(t, exists, "user DTO must not expose %q", key)
}
for _, key := range []string{"name", "description", "groups", "supported_models"} {
_, exists := decoded[key]
require.Truef(t, exists, "user DTO must expose %q", key)
}
// pricing interval 白名单:不应暴露 id / sort_order。
pricing := toUserPricing(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
Intervals: []service.PricingInterval{
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
},
})
require.NotNil(t, pricing)
require.Len(t, pricing.Intervals, 1)
rawIv, err := json.Marshal(pricing.Intervals[0])
require.NoError(t, err)
var ivDecoded map[string]any
require.NoError(t, json.Unmarshal(rawIv, &ivDecoded))
for _, key := range []string{"id", "pricing_id", "sort_order"} {
_, exists := ivDecoded[key]
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
}
}