package service import ( "context" "math" "testing" "time" "gorm.io/gorm" "oci-portal/internal/model" "oci-portal/internal/oci" ) func costItem(day string, amount float32, currency string) oci.CostItem { t, _ := time.Parse("2006-01-02", day) return oci.CostItem{TimeStart: &t, ComputedAmount: amount, Currency: currency} } func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) { // 日期取相对当前的近两天:任务查近 7 天,窗口外的行会被 Costs 过滤 day1 := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02") day2 := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, costItems: []oci.CostItem{ costItem(day1, 5.2, "USD"), costItem(day1, 1.4, "USD"), costItem(day2, 6.3, "USD"), }, } tasks, configs, db := newTaskEnv(t, client) ctx := context.Background() if _, err := configs.Import(ctx, trialImportInput()); err != nil { t.Fatalf("import: %v", err) } free := trialImportInput() free.Alias = "免费号" if _, err := configs.Import(ctx, free); err != nil { t.Fatalf("import free: %v", err) } if err := db.Model(&model.OciConfig{}).Where("id = ?", 2). Update("account_type", model.AccountTypeFree).Error; err != nil { t.Fatalf("mark free: %v", err) } task, err := tasks.CreateTask(ctx, CreateTaskInput{ Name: "成本", Type: model.TaskTypeCost, CronExpr: "30 3 * * *", }) if err != nil { t.Fatalf("CreateTask: %v", err) } entry, err := tasks.RunTaskNow(ctx, task.ID) if err != nil { t.Fatalf("RunTaskNow: %v", err) } if !entry.Success || entry.Message != "synced usage for 1 tenants, skipped 1 free" { t.Errorf("log = %+v, want synced 1 skipped 1", entry) } assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3}) // 再次执行为覆盖更新,不产生重复行 if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { t.Fatalf("RunTaskNow again: %v", err) } assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3}) } // assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。 func assertCostSnapshots(t *testing.T, db *gorm.DB, want map[string]float64) { t.Helper() var snaps []model.CostSnapshot if err := db.Where("oci_config_id = ?", 1).Find(&snaps).Error; err != nil { t.Fatalf("load cost snapshots: %v", err) } if len(snaps) != len(want) { t.Fatalf("snapshots = %d rows, want %d", len(snaps), len(want)) } for _, snap := range snaps { amount, ok := want[snap.Day] // 金额自 float32 累加而来,按容差比较 if !ok || math.Abs(snap.Amount-amount) > 1e-4 { t.Errorf("day %s amount = %v, want %v", snap.Day, snap.Amount, want[snap.Day]) } } } func TestRunHealthCheckWritesSnapshot(t *testing.T) { client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, instances: []oci.Instance{{ID: "i1"}, {ID: "i2"}}, } tasks, configs, db := newTaskEnv(t, client) ctx := context.Background() if _, err := configs.Import(ctx, trialImportInput()); err != nil { t.Fatalf("import: %v", err) } task, err := tasks.CreateTask(ctx, CreateTaskInput{ Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *", }) if err != nil { t.Fatalf("CreateTask: %v", err) } if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { t.Fatalf("RunTaskNow: %v", err) } var snap model.CheckSnapshot if err := db.Where("oci_config_id = ?", 1).First(&snap).Error; err != nil { t.Fatalf("load snapshot: %v", err) } if snap.AliveStatus != model.AliveStatusAlive || snap.InstanceCount != 2 { t.Errorf("snapshot = %+v, want alive with 2 instances", snap) } } func TestOverviewAggregates(t *testing.T) { client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, instances: []oci.Instance{{ID: "i1"}}, costItems: []oci.CostItem{costItem(time.Now().UTC().Format("2006-01-02"), 3.5, "USD")}, } tasks, configs, _ := newTaskEnv(t, client) ctx := context.Background() if _, err := configs.Import(ctx, trialImportInput()); err != nil { t.Fatalf("import: %v", err) } for _, in := range []CreateTaskInput{ {Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *"}, {Name: "成本", Type: model.TaskTypeCost, CronExpr: "30 3 * * *"}, } { task, err := tasks.CreateTask(ctx, in) if err != nil { t.Fatalf("CreateTask %s: %v", in.Name, err) } if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { t.Fatalf("RunTaskNow %s: %v", in.Name, err) } } out, err := configs.Overview(ctx) if err != nil { t.Fatalf("Overview: %v", err) } tests := []struct { name string got interface{} want interface{} }{ {"租户总数", out.Tenants.Total, 1}, {"存活数", out.Tenants.Alive, 1}, {"测活任务存在", out.Check.HasActiveTask, true}, {"测活覆盖", out.Check.CoveredConfigs, 1}, {"实例数", out.Check.InstanceCount, 1}, {"成本任务存在", out.Cost.HasActiveTask, true}, {"成本覆盖", out.Cost.CoveredConfigs, 1}, {"成本合计", out.Cost.Total, 3.5}, {"币种", out.Cost.Currency, "USD"}, {"任务总数", out.Tasks.Total, 2}, {"active 任务", out.Tasks.Active, 2}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.got != tt.want { t.Errorf("got %v, want %v", tt.got, tt.want) } }) } if len(out.Cost.Days) != 1 { t.Errorf("cost days = %d, want 1", len(out.Cost.Days)) } } // TestAggregateCostDaysSplitsCurrencies 锁定多币种聚合:不同币种不相加, // 顶层字段恒为合计最大的主币种,其余币种进 Series。 func TestAggregateCostDaysSplitsCurrencies(t *testing.T) { snaps := []model.CostSnapshot{ {OciConfigID: 1, Day: "2026-07-20", Amount: 1.5, Currency: "USD"}, {OciConfigID: 2, Day: "2026-07-20", Amount: 9.0, Currency: "EUR"}, {OciConfigID: 1, Day: "2026-07-21", Amount: 2.5, Currency: "USD"}, {OciConfigID: 3, Day: "2026-07-21", Amount: 3.0, Currency: "USD"}, } var cost OverviewCost aggregateCostDays(&cost, snaps) if cost.CoveredConfigs != 3 || len(cost.Series) != 2 { t.Fatalf("covered=%d series=%d, want 3, 2", cost.CoveredConfigs, len(cost.Series)) } if cost.Currency != "EUR" || cost.Total != 9.0 { t.Errorf("主币种 = %s %.1f, want EUR 9.0(合计最大)", cost.Currency, cost.Total) } usd := cost.Series[1] if usd.Currency != "USD" || usd.Total != 7.0 || len(usd.Days) != 2 || usd.Days[1].Amount != 5.5 { t.Errorf("USD 序列 = %+v, want 两日合计 7.0 且 21 日 5.5", usd) } }