Backend: one source channel → N output rows, one per platform that has user-visible groups. Each row carries a single platform, so the frontend can color/icon an entire row without mixing sources. - userAvailableChannel: add Platform field - new explodeChannelByPlatform helper; drop now-redundant collectGroupPlatforms Frontend: use the row platform to drive theming and stop repeating "ANTHROPIC" / "OPENAI" labels on every model chip. - api/channels.ts: UserAvailableChannel.platform - AvailableChannelsTable: name cell — PlatformBadge next to channel name (replaces the two-line name/description block; description moves to the badge's title tooltip); groups cell — each chip uses platformBadgeLightClass + PlatformIcon; model list passes show-platform=false + platform-hint to child chips - SupportedModelChip: chip bg/border driven by platformBadgeClass, leading PlatformIcon; platform-hint fallback when model.platform missing
107 lines
3.6 KiB
Go
107 lines
3.6 KiB
Go
//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 TestToUserSupportedModels_FiltersByAllowedPlatforms(t *testing.T) {
|
||
// 用户可访问分组只覆盖 anthropic;anthropic 平台的模型保留,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)
|
||
}
|
||
}
|