Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/schema-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ jobs:
- name: Apply 018 then verify the populated personal-base upgrade
working-directory: database
run: bash ../scripts/with-test-timescale.sh --profile baseline-018 -- go test ./schema -run '^TestPersonalBaseStorageMigration$' -count=1 -v
- name: Apply 021 then verify the populated personal-army upgrade
working-directory: database
run: bash ../scripts/with-test-timescale.sh --profile baseline-021 -- go test ./schema -run '^TestPersonalArmyStorageMigration$' -count=1 -v
- name: Verify daily analytics constraints on the complete retained schema
working-directory: database
run: bash ../scripts/with-test-timescale.sh --profile retained-api -- go test ./migrations -run '^(TestDailyAnalyticsUsageConstraints|TestCWLParticipationTownHalls|TestCWLParticipationHitRates)$' -count=1 -v
128 changes: 128 additions & 0 deletions database/migrations/cwl_participation_constraints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"context"
"os"
"testing"

"github.com/jackc/pgx/v5"
)

func TestCWLParticipationTownHalls(t *testing.T) {
if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" || os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("requires disposable authoritative Goose schema")
}
conn, err := pgx.Connect(t.Context(), os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer conn.Close(context.Background())
for _, tc := range []struct {
name, value string
want bool
}{
{"empty distribution", `[]`, true},
{"ordered distribution", `[{"level":18,"count":4},{"level":17,"count":2}]`, true},
{"null entry", `[null]`, false},
{"string level", `[{"level":"17","count":2}]`, false},
{"negative count", `[{"level":17,"count":-1}]`, false},
{"duplicate level", `[{"level":17,"count":1},{"level":17,"count":2}]`, false},
{"ascending levels", `[{"level":17,"count":1},{"level":18,"count":2}]`, false},
{"unknown key", `[{"level":17,"count":1,"other":true}]`, false},
{"out of range level", `[{"level":21,"count":1}]`, false},
} {
t.Run(tc.name, func(t *testing.T) {
var got bool
if err := conn.QueryRow(t.Context(), "SELECT public.cwl_participation_town_halls_valid($1::jsonb)", tc.value).Scan(&got); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("validator returned %v, want %v", got, tc.want)
}
})
}
for _, tc := range []struct {
name, value string
wantErr bool
}{
{"valid row", `[{"level":18,"count":4}]`, false},
{"malformed row", `[{"level":"18","count":4}]`, true},
} {
t.Run(tc.name, func(t *testing.T) {
tx, err := conn.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
_, err = tx.Exec(t.Context(), `INSERT INTO public.cwl_participation
(season,cwl_league_id,war_size,group_count,clan_count,registered_player_count,townhall_counts,finalized_wars,archived_wars)
VALUES ('2026-09',48000001,15,1,1,1,$1::jsonb,0,0)`, tc.value)
if (err != nil) != tc.wantErr {
t.Fatalf("insert error = %v, want error = %v", err, tc.wantErr)
}
})
}
}

func TestCWLParticipationHitRates(t *testing.T) {
if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" || os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("requires disposable authoritative Goose schema")
}
conn, err := pgx.Connect(t.Context(), os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer conn.Close(context.Background())
for _, tc := range []struct {
name, value string
want bool
}{
{"empty observations", `[]`, true},
{"descending observations", `[{"level":18,"attacks":4,"three_stars":2},{"level":17,"attacks":3,"three_stars":1}]`, true},
{"null entry", `[null]`, false},
{"missing fields", `[{}]`, false},
{"string level", `[{"level":"18","attacks":1,"three_stars":0}]`, false},
{"negative attempts", `[{"level":18,"attacks":-1,"three_stars":0}]`, false},
{"triples over attempts", `[{"level":18,"attacks":1,"three_stars":2}]`, false},
{"duplicate level", `[{"level":18,"attacks":1,"three_stars":0},{"level":18,"attacks":1,"three_stars":0}]`, false},
{"ascending levels", `[{"level":17,"attacks":1,"three_stars":0},{"level":18,"attacks":1,"three_stars":0}]`, false},
{"unknown key", `[{"level":18,"attacks":1,"three_stars":0,"other":true}]`, false},
{"out of range level", `[{"level":21,"attacks":1,"three_stars":0}]`, false},
} {
t.Run(tc.name, func(t *testing.T) {
var got bool
if err := conn.QueryRow(t.Context(), "SELECT public.cwl_participation_hitrates_valid($1::jsonb)", tc.value).Scan(&got); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("validator returned %v, want %v", got, tc.want)
}
})
}
for _, tc := range []struct {
name, value string
wantErr bool
}{
{"SQL null observations", "", false},
{"valid row", `[{"level":18,"attacks":1,"three_stars":1}]`, false},
{"malformed row", `[{}]`, true},
} {
t.Run(tc.name, func(t *testing.T) {
tx, err := conn.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
var input any = tc.value
if tc.name == "SQL null observations" {
input = nil
}
_, err = tx.Exec(t.Context(), `INSERT INTO public.cwl_participation
(season,cwl_league_id,war_size,group_count,clan_count,registered_player_count,townhall_counts,same_th_hitrates,finalized_wars,archived_wars)
VALUES ('2026-09',48000001,15,1,1,1,'[]',$1::jsonb,0,0)`, input)
if (err != nil) != tc.wantErr {
t.Fatalf("insert error = %v, want error = %v", err, tc.wantErr)
}
})
}
}
104 changes: 104 additions & 0 deletions database/migrations/daily_analytics_constraints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"context"
"os"
"testing"

"github.com/jackc/pgx/v5"
)

func TestDailyAnalyticsUsageConstraints(t *testing.T) {
if os.Getenv("CLASHKING_DISPOSABLE_TIMESCALE") != "1" || os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("requires disposable authoritative Goose schema")
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the new fixture-gated tests in CI

In the checked workflows, schema-upgrade.yml invokes only the baseline-006, baseline-013, and baseline-018 profiles, while validate-repository.sh runs go test without a disposable database, so this new analytics test always skips in CI; TestPersonalArmyStorageMigration likewise always skips because it requires baseline-021. Add explicit retained-api and baseline-021 workflow invocations so the new constraint logic and populated 021-to-022 upgrade are actually exercised.

Useful? React with 👍 / 👎.

}
conn, err := pgx.Connect(t.Context(), os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer conn.Close(context.Background())
for _, tc := range []struct {
name, value string
want bool
}{
{"valid selected siege", `[{"id":1,"uses":1,"triples":0}]`, true},
{"zero selected siege", `[{"id":0,"uses":1,"triples":0}]`, false},
} {
t.Run(tc.name, func(t *testing.T) {
var got bool
if err := conn.QueryRow(t.Context(), "SELECT public.legend_selected_siege_ids_valid($1::jsonb)", tc.value).Scan(&got); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("validator returned %v, want %v", got, tc.want)
}
})
}
for _, tc := range []struct {
name, function, value string
want bool
}{
{"valid pet combinations", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[1,2],"uses":2,"triples":1},{"petIds":[2,3],"uses":3,"triples":2}]`, true},
{"duplicate pet combination", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[1,2],"uses":2,"triples":1},{"petIds":[1,2],"uses":2,"triples":1}]`, false},
{"unordered pet combinations", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[2,3],"uses":1,"triples":0},{"petIds":[1,2],"uses":1,"triples":0}]`, false},
{"overcounted pet combinations", "pet_combo_usage_triples_within_attack_count", `[{"petIds":[1],"uses":3,"triples":0},{"petIds":[2],"uses":3,"triples":0}]`, false},
{"valid siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":2},{"id":2,"attacks":3}]`, true},
{"malformed siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":"2"}]`, false},
{"duplicate siege ID", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":2},{"id":1,"attacks":1}]`, false},
{"unordered siege IDs", "army_setup_siege_usage_within_attack_count", `[{"id":2,"attacks":1},{"id":1,"attacks":1}]`, false},
{"overcounted siege usage", "army_setup_siege_usage_within_attack_count", `[{"id":1,"attacks":3},{"id":2,"attacks":3}]`, false},
{"valid Legend siege usage", "legend_siege_usage_within_attack_count", `[{"id":1,"uses":2,"triples":1},{"id":2,"uses":3,"triples":0}]`, true},
{"overcounted Legend siege usage", "legend_siege_usage_within_attack_count", `[{"id":1,"uses":3,"triples":0},{"id":2,"uses":3,"triples":0}]`, false},
{"valid equipment pairs for multiple heroes", "equipment_pair_usage_triples_within_attack_count", `[{"heroId":1,"equipmentIds":[1,2],"uses":3,"triples":0},{"heroId":1,"equipmentIds":[1,3],"uses":2,"triples":0},{"heroId":2,"equipmentIds":[1,2],"uses":5,"triples":0}]`, true},
{"overcounted equipment pairs for one hero", "equipment_pair_usage_triples_within_attack_count", `[{"heroId":1,"equipmentIds":[1,2],"uses":3,"triples":0},{"heroId":1,"equipmentIds":[1,3],"uses":3,"triples":0}]`, false},
} {
t.Run(tc.name, func(t *testing.T) {
var got bool
query := "SELECT public." + tc.function + "($1::jsonb, 5::bigint)"
if err := conn.QueryRow(t.Context(), query, tc.value).Scan(&got); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("validator returned %v, want %v", got, tc.want)
}
})
}
for _, tc := range []struct {
name, value string
perHero bool
want bool
}{
{"pet combos bounded by three stars", `[{"petIds":[1],"uses":1,"triples":1},{"petIds":[2],"uses":1,"triples":1}]`, false, true},
{"pet combos exceed three stars", `[{"petIds":[1],"uses":3,"triples":3}]`, false, false},
{"siege uses exceed three stars", `[{"id":1,"uses":1,"triples":1},{"id":2,"uses":1,"triples":2}]`, false, false},
{"equipment pairs bounded per hero", `[{"heroId":1,"equipmentIds":[1,2],"uses":2,"triples":2},{"heroId":2,"equipmentIds":[1,2],"uses":2,"triples":2}]`, true, true},
{"equipment pairs exceed three stars for hero", `[{"heroId":1,"equipmentIds":[1,2],"uses":2,"triples":2},{"heroId":1,"equipmentIds":[1,3],"uses":1,"triples":1}]`, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
var got bool
if err := conn.QueryRow(t.Context(), "SELECT public.legend_exclusive_triples_within_star_count($1::jsonb, 2::bigint, $2::boolean)", tc.value, tc.perHero).Scan(&got); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("validator returned %v, want %v", got, tc.want)
}
})
}
for _, tc := range []struct {
name, value string
want bool
}{
{"valid item triples for co-occurring items", `[{"id":1,"uses":3,"triples":2},{"id":2,"uses":3,"triples":2}]`, true},
{"item triples exceed three stars", `[{"id":1,"uses":3,"triples":3}]`, false},
} {
t.Run(tc.name, func(t *testing.T) {
var got bool
if err := conn.QueryRow(t.Context(), "SELECT public.legend_item_triples_within_star_count($1::jsonb, 2::bigint)", tc.value).Scan(&got); err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("validator returned %v, want %v", got, tc.want)
}
})
}
}
36 changes: 36 additions & 0 deletions database/migrations/legend_daily_metadata_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package main

import (
"os"
"strings"
"testing"
)

func TestLegendDailyMetadataMigrationIsAdditive(t *testing.T) {
raw, err := os.ReadFile("../timescale/030_legend_daily_metadata.sql")
if err != nil {
t.Fatal(err)
}
sql := string(raw)
for _, expected := range []string{
"'top_200','top_100'",
"ADD COLUMN troop_stats jsonb NOT NULL DEFAULT '[]'::jsonb",
"ADD COLUMN spell_stats jsonb NOT NULL DEFAULT '[]'::jsonb",
"ADD COLUMN siege_stats jsonb NOT NULL DEFAULT '[]'::jsonb",
"ADD COLUMN equipment_pair_stats jsonb NOT NULL DEFAULT '[]'::jsonb",
"equipment_pair_usage_triples_within_attack_count",
} {
if !strings.Contains(sql, expected) {
t.Fatalf("migration is missing %q", expected)
}
}
petRaw, err := os.ReadFile("../timescale/031_legend_daily_pet_combos.sql")
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"ADD COLUMN pet_combo_stats jsonb NOT NULL DEFAULT '[]'::jsonb", "pet_combo_usage_triples_within_attack_count"} {
if !strings.Contains(string(petRaw), expected) {
t.Fatalf("migration 031 is missing %q", expected)
}
}
}
23 changes: 23 additions & 0 deletions database/migrations/war_archive_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ func TestTimescaleMigrationsAreExplicitlyNumbered(t *testing.T) {
"019_unlimited_personal_bases.sql",
"020_player_leaderboard_snapshots.sql",
"021_inline_base_images_votes.sql",
"022_personal_army_library.sql",
"023_roster_signup_scope.sql",
"024_roster_admission_settings.sql",
"025_roster_discord_publications.sql",
"026_roster_publication_webhooks.sql",
"027_roster_embed_color.sql",
"028_roster_automation_event_offsets.sql",
"029_roster_default_capacity.sql",
"030_legend_daily_metadata.sql",
"031_legend_daily_pet_combos.sql",
"032_remove_server_link_token_policy.sql",
"033_cwl_participation.sql",
"034_daily_army_setups.sql",
"035_remove_army_analysis_timestamp.sql",
"036_army_setup_siege_usage.sql",
"037_roster_publication_requests.sql",
"038_daily_analytics_count_constraints.sql",
"039_legend_daily_exclusive_usage.sql",
"040_legend_exclusive_triple_totals.sql",
"041_legend_item_triples_saved_army_index.sql",
"042_cwl_participation_townhall_validation.sql",
"043_legend_selected_siege_id.sql",
"044_cwl_participation_hitrate_validation.sql",
}
if len(files) != len(want) {
t.Fatalf("Timescale migrations = %v, want %v", files, want)
Expand Down
44 changes: 38 additions & 6 deletions database/schema/final_operational_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func TestBasesUseBigintRelationsAndPrivateVotes(t *testing.T) {
}
}

func TestPersonalBaseLibraryIsUnlimitedAndTyped(t *testing.T) {
func TestPersonalLibrariesUseCanonicalBaseAndArmyIdentity(t *testing.T) {
conn := disposableConn(t)
ctx := context.Background()
tx, err := conn.Begin(ctx)
Expand All @@ -149,18 +149,50 @@ func TestPersonalBaseLibraryIsUnlimitedAndTyped(t *testing.T) {
'https://link.clashofclans.com/en?action=OpenLayout&id=TH17%3AWB%3A'||value,
'Personal base '||value
FROM generate_series(1,25) value;
INSERT INTO user_saved_bases(user_id,base_id,kind)
SELECT 'owner',id,CASE id%3 WHEN 0 THEN NULL WHEN 1 THEN 'war' ELSE 'legend' END
FROM bases WHERE description LIKE 'Personal base %'`)
INSERT INTO user_saved_bases(user_id,base_id)
SELECT 'owner',id FROM bases WHERE description LIKE 'Personal base %';
INSERT INTO army_compositions(share_code) VALUES('u1x0');
INSERT INTO user_saved_armies(user_id,share_code)
VALUES('owner','u1x0')`)
if err != nil {
t.Fatal(err)
}
var savedCount int
if err = tx.QueryRow(ctx, `SELECT count(*) FROM user_saved_bases WHERE user_id='owner'`).Scan(&savedCount); err != nil || savedCount != 25 {
t.Fatalf("unlimited saved rows=%d err=%v", savedCount, err)
}
if _, err = tx.Exec(ctx, `UPDATE user_saved_bases SET kind='farming' WHERE user_id='owner'`); err == nil {
t.Fatal("invalid saved-base kind accepted")
var kindAbsent bool
if err = tx.QueryRow(ctx, `SELECT NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='user_saved_bases' AND column_name='kind'
)`).Scan(&kindAbsent); err != nil || !kindAbsent {
t.Fatalf("saved-base kind still exists: %v", err)
}
var armySaved bool
if err = tx.QueryRow(ctx, `SELECT saved_at IS NOT NULL FROM user_saved_armies
WHERE user_id='owner' AND share_code='u1x0'`).Scan(&armySaved); err != nil || !armySaved {
t.Fatalf("personal army save missing: %v", err)
}
for _, q := range []string{
`INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','u1x0')`,
`INSERT INTO user_saved_armies(user_id,share_code) VALUES('owner','missing')`,
} {
if _, err = tx.Exec(ctx, `SAVEPOINT invalid_personal_army`); err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(ctx, q); err == nil {
t.Fatalf("invalid personal army accepted: %s", q)
}
if _, err = tx.Exec(ctx, `ROLLBACK TO SAVEPOINT invalid_personal_army`); err != nil {
t.Fatal(err)
}
}
if _, err = tx.Exec(ctx, `DELETE FROM user_saved_armies WHERE user_id='owner' AND share_code='u1x0'`); err != nil {
t.Fatal(err)
}
var compositionPresent bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM army_compositions WHERE share_code='u1x0')`).Scan(&compositionPresent); err != nil || !compositionPresent {
t.Fatalf("unsave deleted canonical composition: %v", err)
}
if err = tx.Rollback(ctx); err != nil {
t.Fatal(err)
Expand Down
Loading
Loading