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 }