File size: 4,855 Bytes
9c1c54e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e54caa
9c1c54e
 
1e54caa
 
 
9c1c54e
 
 
1e54caa
 
 
9c1c54e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
 * Tests for plan-based model routing.
 *
 * Verifies that:
 * 1. applyBackendModelsForPlan correctly builds planModelMap
 * 2. getModelPlanTypes returns correct plan associations
 * 3. When both free and team plans include a model, both are returned
 * 4. Account pool respects plan routing when acquiring accounts
 */

import { describe, it, expect, beforeEach, vi } from "vitest";

vi.mock("../../config.js", () => ({
  getConfig: vi.fn(() => ({
    server: {},
    model: {},
    api: { base_url: "https://chatgpt.com/backend-api" },
    client: { app_version: "1.0.0" },
  })),
}));

vi.mock("fs", async (importOriginal) => {
  const actual = await importOriginal<typeof import("fs")>();
  return {
    ...actual,
    readFileSync: vi.fn(() => "models: []"),
    writeFileSync: vi.fn(),
    writeFile: vi.fn((_p: string, _d: string, _e: string, cb: (err: Error | null) => void) => cb(null)),
    existsSync: vi.fn(() => false),
    mkdirSync: vi.fn(),
  };
});

vi.mock("js-yaml", () => ({
  default: {
    load: vi.fn(() => ({ models: [], aliases: {} })),
    dump: vi.fn(() => ""),
  },
}));

import {
  loadStaticModels,
  applyBackendModelsForPlan,
  getModelPlanTypes,
  getModelStoreDebug,
} from "../model-store.js";

// Minimal backend model entry matching what Codex API returns
function makeModel(slug: string) {
  return { slug, id: slug, name: slug };
}

describe("plan-based model routing", () => {
  beforeEach(() => {
    // Reset model store state by reloading empty static catalog
    loadStaticModels();
  });

  it("applyBackendModelsForPlan registers models for a plan", () => {
    applyBackendModelsForPlan("free", [
      makeModel("gpt-5.2-codex"),
      makeModel("gpt-5.4"),
    ]);

    expect(getModelPlanTypes("gpt-5.2-codex")).toContain("free");
    expect(getModelPlanTypes("gpt-5.4")).toContain("free");
  });

  it("models available in both plans return both plan types", () => {
    applyBackendModelsForPlan("free", [
      makeModel("gpt-5.2-codex"),
      makeModel("gpt-5.4"),
    ]);
    applyBackendModelsForPlan("team", [
      makeModel("gpt-5.2-codex"),
      makeModel("gpt-5.4"),
      makeModel("gpt-5.4-mini"),
    ]);

    const plans54 = getModelPlanTypes("gpt-5.4");
    expect(plans54).toContain("free");
    expect(plans54).toContain("team");

    const plansCodex = getModelPlanTypes("gpt-5.2-codex");
    expect(plansCodex).toContain("free");
    expect(plansCodex).toContain("team");
  });

  it("model only in team plan does not include free", () => {
    applyBackendModelsForPlan("free", [
      makeModel("gpt-5.2-codex"),
    ]);
    applyBackendModelsForPlan("team", [
      makeModel("gpt-5.2-codex"),
      makeModel("gpt-5.4"),
    ]);

    const plans54 = getModelPlanTypes("gpt-5.4");
    expect(plans54).toContain("team");
    expect(plans54).not.toContain("free");
  });

  it("replacing a plan's models updates the index", () => {
    // Initially free doesn't have gpt-5.4
    applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
    expect(getModelPlanTypes("gpt-5.4")).not.toContain("free");

    // Backend now returns gpt-5.4 for free → re-fetch
    applyBackendModelsForPlan("free", [
      makeModel("gpt-5.2-codex"),
      makeModel("gpt-5.4"),
    ]);
    expect(getModelPlanTypes("gpt-5.4")).toContain("free");
  });

  it("unknown model returns empty plan list", () => {
    applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
    expect(getModelPlanTypes("nonexistent-model")).toEqual([]);
  });

  it("all backend model slugs are admitted (no client-side filtering)", () => {
    applyBackendModelsForPlan("free", [
      makeModel("gpt-5.2-codex"),
      makeModel("research"),
      makeModel("gpt-5-2"),
      makeModel("some-internal-slug"),
    ]);

    expect(getModelPlanTypes("gpt-5.2-codex")).toContain("free");
    expect(getModelPlanTypes("research")).toContain("free");
    expect(getModelPlanTypes("gpt-5-2")).toContain("free");
    expect(getModelPlanTypes("some-internal-slug")).toContain("free");
  });

  it("gpt-oss-* models are admitted", () => {
    applyBackendModelsForPlan("free", [
      makeModel("gpt-oss-120b"),
      makeModel("gpt-oss-20b"),
    ]);

    expect(getModelPlanTypes("gpt-oss-120b")).toContain("free");
    expect(getModelPlanTypes("gpt-oss-20b")).toContain("free");
  });

  it("planMap in store info reflects current state", () => {
    applyBackendModelsForPlan("free", [
      makeModel("gpt-5.2-codex"),
      makeModel("gpt-5.4"),
    ]);
    applyBackendModelsForPlan("team", [
      makeModel("gpt-5.4"),
    ]);

    const info = getModelStoreDebug();
    expect(info.planMap.free).toContain("gpt-5.2-codex");
    expect(info.planMap.free).toContain("gpt-5.4");
    expect(info.planMap.team).toContain("gpt-5.4");
    expect(info.planMap.team).not.toContain("gpt-5.2-codex");
  });
});