icebear0828 commited on
Commit
9c1c54e
·
1 Parent(s): 1562152

test: add plan-based model routing tests (14 cases)

Browse files

- model-store: plan map build, reverse index, plan updates, non-Codex
slug filtering, gpt-oss admission, store debug output
- account-pool: plan-matched acquire, team-only rejection, fallback
on empty plans, plan map update re-routing, preference ordering

Covers the exact code path where free accounts get blocked from models
that the backend has since opened to free tier (gpt-5.4 case).

src/auth/__tests__/plan-routing-acquire.test.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests that account-pool correctly routes requests based on model→plan mapping.
3
+ *
4
+ * Verifies the critical path: when a model is available to both free and team,
5
+ * free accounts should be selected. When only team has it, free accounts must
6
+ * NOT be used (return null instead of wrong account).
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+
11
+ // Mock getModelPlanTypes to control plan routing
12
+ const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
13
+
14
+ vi.mock("../../models/model-store.js", () => ({
15
+ getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
16
+ }));
17
+
18
+ vi.mock("../../config.js", () => ({
19
+ getConfig: vi.fn(() => ({
20
+ server: { account_strategy: "round_robin" },
21
+ auth: { jwt_token: "" },
22
+ })),
23
+ }));
24
+
25
+ // Control planType by returning it from extractUserProfile
26
+ let profileForToken: Record<string, { chatgpt_plan_type: string; email: string }> = {};
27
+
28
+ vi.mock("../../auth/jwt-utils.js", () => ({
29
+ isTokenExpired: vi.fn(() => false),
30
+ decodeJwtPayload: vi.fn(() => ({})),
31
+ extractChatGptAccountId: vi.fn((token: string) => `aid-${token}`),
32
+ extractUserProfile: vi.fn((token: string) => profileForToken[token] ?? null),
33
+ }));
34
+
35
+ vi.mock("../../utils/jitter.js", () => ({
36
+ jitter: vi.fn((val: number) => val),
37
+ }));
38
+
39
+ vi.mock("fs", () => ({
40
+ readFileSync: vi.fn(() => JSON.stringify({ accounts: [] })),
41
+ writeFileSync: vi.fn(),
42
+ existsSync: vi.fn(() => false),
43
+ mkdirSync: vi.fn(),
44
+ }));
45
+
46
+ import { AccountPool } from "../account-pool.js";
47
+
48
+ function createPool(...accounts: Array<{ token: string; planType: string; email: string }>) {
49
+ // Set up profile mocks before creating pool
50
+ profileForToken = {};
51
+ for (const a of accounts) {
52
+ profileForToken[a.token] = { chatgpt_plan_type: a.planType, email: a.email };
53
+ }
54
+
55
+ const pool = new AccountPool();
56
+ for (const a of accounts) {
57
+ pool.addAccount(a.token);
58
+ }
59
+ return pool;
60
+ }
61
+
62
+ describe("account-pool plan-based routing", () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ profileForToken = {};
66
+ });
67
+
68
+ it("returns null when model only supports team but only free accounts exist", () => {
69
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
70
+ const pool = createPool(
71
+ { token: "tok-free", planType: "free", email: "free@test.com" },
72
+ );
73
+
74
+ const acquired = pool.acquire({ model: "gpt-5.4" });
75
+ expect(acquired).toBeNull();
76
+ });
77
+
78
+ it("acquires team account when model only supports team", () => {
79
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
80
+ const pool = createPool(
81
+ { token: "tok-free", planType: "free", email: "free@test.com" },
82
+ { token: "tok-team", planType: "team", email: "team@test.com" },
83
+ );
84
+
85
+ const acquired = pool.acquire({ model: "gpt-5.4" });
86
+ expect(acquired).not.toBeNull();
87
+ expect(acquired!.token).toBe("tok-team");
88
+ });
89
+
90
+ it("uses any account when model has no known plan requirements", () => {
91
+ mockGetModelPlanTypes.mockReturnValue([]);
92
+ const pool = createPool(
93
+ { token: "tok-free", planType: "free", email: "free@test.com" },
94
+ );
95
+
96
+ const acquired = pool.acquire({ model: "unknown-model" });
97
+ expect(acquired).not.toBeNull();
98
+ });
99
+
100
+ it("after plan map update, free account can access previously team-only model", () => {
101
+ const pool = createPool(
102
+ { token: "tok-free", planType: "free", email: "free@test.com" },
103
+ );
104
+
105
+ // Initially: gpt-5.4 only for team
106
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
107
+ const before = pool.acquire({ model: "gpt-5.4" });
108
+ expect(before).toBeNull(); // blocked
109
+
110
+ // Backend updates: gpt-5.4 now available for free too
111
+ mockGetModelPlanTypes.mockReturnValue(["free", "team"]);
112
+ const after = pool.acquire({ model: "gpt-5.4" });
113
+ expect(after).not.toBeNull();
114
+ expect(after!.token).toBe("tok-free");
115
+ });
116
+
117
+ it("prefers plan-matched accounts over others", () => {
118
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
119
+ const pool = createPool(
120
+ { token: "tok-free1", planType: "free", email: "free1@test.com" },
121
+ { token: "tok-free2", planType: "free", email: "free2@test.com" },
122
+ { token: "tok-team", planType: "team", email: "team@test.com" },
123
+ );
124
+
125
+ const acquired = pool.acquire({ model: "gpt-5.4" });
126
+ expect(acquired).not.toBeNull();
127
+ expect(acquired!.token).toBe("tok-team");
128
+ });
129
+
130
+ it("acquires free account when model supports both free and team", () => {
131
+ mockGetModelPlanTypes.mockReturnValue(["free", "team"]);
132
+ const pool = createPool(
133
+ { token: "tok-free", planType: "free", email: "free@test.com" },
134
+ { token: "tok-team", planType: "team", email: "team@test.com" },
135
+ );
136
+
137
+ const acquired = pool.acquire({ model: "gpt-5.4" });
138
+ expect(acquired).not.toBeNull();
139
+ // Both are valid candidates, should get one of them
140
+ expect(["tok-free", "tok-team"]).toContain(acquired!.token);
141
+ });
142
+ });
src/models/__tests__/plan-routing.test.ts ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for plan-based model routing.
3
+ *
4
+ * Verifies that:
5
+ * 1. applyBackendModelsForPlan correctly builds planModelMap
6
+ * 2. getModelPlanTypes returns correct plan associations
7
+ * 3. When both free and team plans include a model, both are returned
8
+ * 4. Account pool respects plan routing when acquiring accounts
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, vi } from "vitest";
12
+
13
+ vi.mock("../../config.js", () => ({
14
+ getConfig: vi.fn(() => ({
15
+ server: {},
16
+ model: {},
17
+ api: { base_url: "https://chatgpt.com/backend-api" },
18
+ client: { app_version: "1.0.0" },
19
+ })),
20
+ }));
21
+
22
+ vi.mock("fs", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("fs")>();
24
+ return {
25
+ ...actual,
26
+ readFileSync: vi.fn(() => "models: []"),
27
+ writeFileSync: vi.fn(),
28
+ writeFile: vi.fn((_p: string, _d: string, _e: string, cb: (err: Error | null) => void) => cb(null)),
29
+ existsSync: vi.fn(() => false),
30
+ mkdirSync: vi.fn(),
31
+ };
32
+ });
33
+
34
+ vi.mock("js-yaml", () => ({
35
+ default: {
36
+ load: vi.fn(() => ({ models: [], aliases: {} })),
37
+ dump: vi.fn(() => ""),
38
+ },
39
+ }));
40
+
41
+ import {
42
+ loadStaticModels,
43
+ applyBackendModelsForPlan,
44
+ getModelPlanTypes,
45
+ getModelStoreDebug,
46
+ } from "../model-store.js";
47
+
48
+ // Minimal backend model entry matching what Codex API returns
49
+ function makeModel(slug: string) {
50
+ return { slug, id: slug, name: slug };
51
+ }
52
+
53
+ describe("plan-based model routing", () => {
54
+ beforeEach(() => {
55
+ // Reset model store state by reloading empty static catalog
56
+ loadStaticModels();
57
+ });
58
+
59
+ it("applyBackendModelsForPlan registers models for a plan", () => {
60
+ applyBackendModelsForPlan("free", [
61
+ makeModel("gpt-5.2-codex"),
62
+ makeModel("gpt-5.4"),
63
+ ]);
64
+
65
+ expect(getModelPlanTypes("gpt-5.2-codex")).toContain("free");
66
+ expect(getModelPlanTypes("gpt-5.4")).toContain("free");
67
+ });
68
+
69
+ it("models available in both plans return both plan types", () => {
70
+ applyBackendModelsForPlan("free", [
71
+ makeModel("gpt-5.2-codex"),
72
+ makeModel("gpt-5.4"),
73
+ ]);
74
+ applyBackendModelsForPlan("team", [
75
+ makeModel("gpt-5.2-codex"),
76
+ makeModel("gpt-5.4"),
77
+ makeModel("gpt-5.4-mini"),
78
+ ]);
79
+
80
+ const plans54 = getModelPlanTypes("gpt-5.4");
81
+ expect(plans54).toContain("free");
82
+ expect(plans54).toContain("team");
83
+
84
+ const plansCodex = getModelPlanTypes("gpt-5.2-codex");
85
+ expect(plansCodex).toContain("free");
86
+ expect(plansCodex).toContain("team");
87
+ });
88
+
89
+ it("model only in team plan does not include free", () => {
90
+ applyBackendModelsForPlan("free", [
91
+ makeModel("gpt-5.2-codex"),
92
+ ]);
93
+ applyBackendModelsForPlan("team", [
94
+ makeModel("gpt-5.2-codex"),
95
+ makeModel("gpt-5.4"),
96
+ ]);
97
+
98
+ const plans54 = getModelPlanTypes("gpt-5.4");
99
+ expect(plans54).toContain("team");
100
+ expect(plans54).not.toContain("free");
101
+ });
102
+
103
+ it("replacing a plan's models updates the index", () => {
104
+ // Initially free doesn't have gpt-5.4
105
+ applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
106
+ expect(getModelPlanTypes("gpt-5.4")).not.toContain("free");
107
+
108
+ // Backend now returns gpt-5.4 for free → re-fetch
109
+ applyBackendModelsForPlan("free", [
110
+ makeModel("gpt-5.2-codex"),
111
+ makeModel("gpt-5.4"),
112
+ ]);
113
+ expect(getModelPlanTypes("gpt-5.4")).toContain("free");
114
+ });
115
+
116
+ it("unknown model returns empty plan list", () => {
117
+ applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
118
+ expect(getModelPlanTypes("nonexistent-model")).toEqual([]);
119
+ });
120
+
121
+ it("non-Codex model slugs are filtered out", () => {
122
+ applyBackendModelsForPlan("free", [
123
+ makeModel("gpt-5.2-codex"),
124
+ makeModel("research"), // not Codex-compatible
125
+ makeModel("gpt-5-2"), // hyphen instead of dot
126
+ makeModel("some-internal-slug"), // not Codex-compatible
127
+ ]);
128
+
129
+ expect(getModelPlanTypes("gpt-5.2-codex")).toContain("free");
130
+ expect(getModelPlanTypes("research")).toEqual([]);
131
+ expect(getModelPlanTypes("gpt-5-2")).toEqual([]);
132
+ expect(getModelPlanTypes("some-internal-slug")).toEqual([]);
133
+ });
134
+
135
+ it("gpt-oss-* models are admitted", () => {
136
+ applyBackendModelsForPlan("free", [
137
+ makeModel("gpt-oss-120b"),
138
+ makeModel("gpt-oss-20b"),
139
+ ]);
140
+
141
+ expect(getModelPlanTypes("gpt-oss-120b")).toContain("free");
142
+ expect(getModelPlanTypes("gpt-oss-20b")).toContain("free");
143
+ });
144
+
145
+ it("planMap in store info reflects current state", () => {
146
+ applyBackendModelsForPlan("free", [
147
+ makeModel("gpt-5.2-codex"),
148
+ makeModel("gpt-5.4"),
149
+ ]);
150
+ applyBackendModelsForPlan("team", [
151
+ makeModel("gpt-5.4"),
152
+ ]);
153
+
154
+ const info = getModelStoreDebug();
155
+ expect(info.planMap.free).toContain("gpt-5.2-codex");
156
+ expect(info.planMap.free).toContain("gpt-5.4");
157
+ expect(info.planMap.team).toContain("gpt-5.4");
158
+ expect(info.planMap.team).not.toContain("gpt-5.2-codex");
159
+ });
160
+ });