package main import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/aibridge/prices/pricebook" ) func TestCompare(t *testing.T) { t.Parallel() cases := []struct { name string old []pricebook.Row new []pricebook.Row wantAdded []string wantRemoved []string wantChanged []string }{ { name: "identical", old: []pricebook.Row{row("anthropic", "claude", 1, 2)}, new: []pricebook.Row{row("anthropic", "claude", 1, 2)}, }, { name: "added", old: []pricebook.Row{ row("anthropic", "claude", 1, 2), }, new: []pricebook.Row{ row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4), }, wantAdded: []string{"openai/gpt"}, }, { name: "removed", old: []pricebook.Row{ row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4), }, new: []pricebook.Row{ row("anthropic", "claude", 1, 2), }, wantRemoved: []string{"openai/gpt"}, }, { name: "changed", old: []pricebook.Row{row("anthropic", "claude", 1, 2)}, new: []pricebook.Row{row("anthropic", "claude", 1, 5)}, wantChanged: []string{"anthropic/claude"}, }, { // Every price field participates, not just input and output. name: "cache price changed", old: []pricebook.Row{{ Provider: "anthropic", Model: "claude", CacheReadPrice: int64Ptr(1), }}, new: []pricebook.Row{{ Provider: "anthropic", Model: "claude", CacheReadPrice: int64Ptr(2), }}, wantChanged: []string{"anthropic/claude"}, }, { // A previously missing price becoming populated is a change. name: "price set", old: []pricebook.Row{{ Provider: "anthropic", Model: "claude", }}, new: []pricebook.Row{{ Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(1), }}, wantChanged: []string{"anthropic/claude"}, }, { // A model whose price becomes null is a change, not a removal. name: "price unset", old: []pricebook.Row{ row("anthropic", "claude", 1, 2), }, new: []pricebook.Row{{ Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(1), }}, wantChanged: []string{"anthropic/claude"}, }, { // Zero is a populated price, distinct from an absent one. name: "null becomes zero", old: []pricebook.Row{{ Provider: "openai", Model: "gpt", }}, new: []pricebook.Row{{ Provider: "openai", Model: "gpt", InputPrice: int64Ptr(0), }}, wantChanged: []string{"openai/gpt"}, }, { // Zero is a real price, distinct from an absent one. name: "zero is not null", old: []pricebook.Row{{ Provider: "openai", Model: "gpt", InputPrice: int64Ptr(0), }}, new: []pricebook.Row{{ Provider: "openai", Model: "gpt", }}, wantChanged: []string{"openai/gpt"}, }, { // Same model identifier under two providers must not collide. name: "same model different providers", old: []pricebook.Row{ row("anthropic", "shared", 1, 2), row("openai", "shared", 1, 2), }, new: []pricebook.Row{ row("anthropic", "shared", 1, 2), row("openai", "shared", 9, 2), }, wantChanged: []string{"openai/shared"}, }, { name: "added removed and changed together", old: []pricebook.Row{ row("anthropic", "old-model", 1, 2), row("openai", "gpt", 3, 4), }, new: []pricebook.Row{ row("anthropic", "new-model", 5, 6), row("openai", "gpt", 3, 7), }, wantAdded: []string{"anthropic/new-model"}, wantRemoved: []string{"anthropic/old-model"}, wantChanged: []string{"openai/gpt"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := compare(tc.old, tc.new) require.Equal(t, tc.wantAdded, names(got.added)) require.Equal(t, tc.wantRemoved, names(got.removed)) require.Equal(t, tc.wantChanged, names(got.changed)) }) } } func TestCompareSortsDeterministically(t *testing.T) { t.Parallel() newRows := []pricebook.Row{ row("openai", "b", 1, 1), row("anthropic", "z", 1, 1), row("anthropic", "a", 1, 1), } got := compare(nil, newRows) require.Equal(t, []string{"anthropic/a", "anthropic/z", "openai/b"}, names(got.added)) } func TestRender(t *testing.T) { t.Parallel() t.Run("no changes", func(t *testing.T) { t.Parallel() out := render(compare( []pricebook.Row{row("anthropic", "claude", 1, 2)}, []pricebook.Row{row("anthropic", "claude", 1, 2)}, )) require.Equal(t, `## Price book changes No price changes. `, out) }) t.Run("full summary", func(t *testing.T) { t.Parallel() out := render(compare( []pricebook.Row{ row("anthropic", "gone", 1, 2), row("openai", "gpt", 1, 2), }, []pricebook.Row{ row("anthropic", "fresh", 3, 4), row("openai", "gpt", 2, 3), }, )) // Asserted whole rather than by substring so section order, spacing, // and the absence of stray content are covered too. require.Equal(t, `## Price book changes 1 model added, 1 model removed, 1 model changed.
Added - anthropic/fresh
Removed - anthropic/gone
Changed - openai/gpt
`, out) }) } func TestRun(t *testing.T) { t.Parallel() t.Run("missing flags", func(t *testing.T) { t.Parallel() var out strings.Builder require.ErrorContains(t, run("", "", &out), "-old and -new are both required") }) t.Run("reads files", func(t *testing.T) { t.Parallel() dir := t.TempDir() oldPath := filepath.Join(dir, "old.json") newPath := filepath.Join(dir, "new.json") writeFile(t, oldPath, `[{"provider":"openai","model":"gpt","input_price":1000000,"output_price":2000000,"cache_read_price":null,"cache_write_price":null}]`) writeFile(t, newPath, `[{"provider":"openai","model":"gpt","input_price":1500000,"output_price":2000000,"cache_read_price":null,"cache_write_price":null}]`) var out strings.Builder require.NoError(t, run(oldPath, newPath, &out)) require.Contains(t, out.String(), "Changed\n\n- openai/gpt\n") }) t.Run("invalid json", func(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "bad.json") writeFile(t, path, "{") var out strings.Builder err := run(path, path, &out) // The path is part of the message so a failed refresh names the file // that could not be read. require.ErrorContains(t, err, path) require.ErrorContains(t, err, "parse:") }) t.Run("missing file", func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "absent.json") var out strings.Builder err := run(path, path, &out) require.ErrorIs(t, err, os.ErrNotExist) require.ErrorContains(t, err, path) }) } func row(provider, model string, input, output int64) pricebook.Row { return pricebook.Row{ Provider: provider, Model: model, InputPrice: int64Ptr(input), OutputPrice: int64Ptr(output), } } func names(keys []pricebook.Key) []string { if len(keys) == 0 { return nil } out := make([]string, 0, len(keys)) for _, k := range keys { out = append(out, k.String()) } return out } func writeFile(t *testing.T, path, content string) { t.Helper() require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) } func int64Ptr(v int64) *int64 { return &v }