mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
fix: aggregate dashboard trends by database dialect
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
@@ -28,25 +29,40 @@ type AddressDailyStat struct {
|
|||||||
ActualAmount float64 `json:"actual_amount"`
|
ActualAmount float64 `json:"actual_amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statsBucketExpr(column string, hourly bool) (string, error) {
|
||||||
|
dialect := "sqlite"
|
||||||
|
if dao.Mdb != nil && dao.Mdb.Dialector != nil {
|
||||||
|
dialect = dao.Mdb.Dialector.Name()
|
||||||
|
}
|
||||||
|
return statsBucketExprForDialect(dialect, column, hourly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsBucketExprForDialect(dialect, column string, hourly bool) (string, error) {
|
||||||
|
switch dialect {
|
||||||
|
case "sqlite", "":
|
||||||
|
// modernc SQLite stores time.Time as Go's time.String() by default.
|
||||||
|
// Prefix buckets work for that format and SQLite datetime text.
|
||||||
|
if hourly {
|
||||||
|
return fmt.Sprintf("replace(substr(%s, 1, 13), 'T', ' ') || ':00'", column), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("substr(%s, 1, 10)", column), nil
|
||||||
|
case "postgres":
|
||||||
|
if hourly {
|
||||||
|
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD HH24:00')", column), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD')", column), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported database dialect for dashboard stats: %s", dialect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||||
// zero-filling any days in the range that have no orders.
|
// zero-filling any days in the range that have no orders.
|
||||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||||
// what the user means by "流水". Pending orders count toward OrderCount
|
// what the user means by "流水". Pending orders count toward OrderCount
|
||||||
// to keep成交率 computable.
|
// to keep成交率 computable.
|
||||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||||
var rows []DailyStat
|
rows, err := queryOrderStats(start, end, false)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select(`DATE(created_at) AS day,
|
|
||||||
COUNT(*) AS order_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
|
||||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
|
||||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("DATE(created_at)").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -56,25 +72,34 @@ func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
|||||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||||
// zero-filling any hours that have no orders.
|
// zero-filling any hours that have no orders.
|
||||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||||
var rows []DailyStat
|
rows, err := queryOrderStats(start, end, true)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
|
|
||||||
COUNT(*) AS order_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
|
||||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
|
||||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("strftime('%Y-%m-%d %H:00', created_at)").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return fillHourlyStats(start, end, rows), nil
|
return fillHourlyStats(start, end, rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryOrderStats(start, end time.Time, hourly bool) ([]DailyStat, error) {
|
||||||
|
var rows []DailyStat
|
||||||
|
bucket, err := statsBucketExpr("created_at", hourly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Select(fmt.Sprintf(`%s AS day,
|
||||||
|
COUNT(*) AS order_count,
|
||||||
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||||
|
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||||
|
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, bucket),
|
||||||
|
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||||
|
Where("created_at >= ?", start).
|
||||||
|
Where("created_at <= ?", end).
|
||||||
|
Group(bucket).
|
||||||
|
Order("day ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||||
@@ -122,15 +147,7 @@ func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
|||||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||||
// for the per-address stacked chart, zero-filling missing days.
|
// for the per-address stacked chart, zero-filling missing days.
|
||||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||||
var rows []AddressDailyStat
|
rows, err := queryAssetByAddress(start, end, false)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
|
||||||
Where("status = ?", mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("DATE(created_at), receive_address").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -140,21 +157,33 @@ func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
|||||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||||
// for the per-address stacked chart, zero-filling missing hours.
|
// for the per-address stacked chart, zero-filling missing hours.
|
||||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||||
var rows []AddressDailyStat
|
rows, err := queryAssetByAddress(start, end, true)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
|
||||||
Where("status = ?", mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return fillAddressHourlyStats(start, end, rows), nil
|
return fillAddressHourlyStats(start, end, rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryAssetByAddress(start, end time.Time, hourly bool) ([]AddressDailyStat, error) {
|
||||||
|
var rows []AddressDailyStat
|
||||||
|
bucket, err := statsBucketExpr("created_at", hourly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Select(fmt.Sprintf(`%s AS day,
|
||||||
|
receive_address AS address,
|
||||||
|
SUM(actual_amount) AS actual_amount`, bucket)).
|
||||||
|
Where("status = ?", mdb.StatusPaySuccess).
|
||||||
|
Where("created_at >= ?", start).
|
||||||
|
Where("created_at <= ?", end).
|
||||||
|
Group(bucket).
|
||||||
|
Group("receive_address").
|
||||||
|
Order("day ASC, address ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||||
// All addresses that appear in rows are represented for every day in range.
|
// All addresses that appear in rows are represented for every day in range.
|
||||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||||
|
|||||||
@@ -42,6 +42,56 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsBucketExprForDialect(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dialect string
|
||||||
|
hourly bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sqlite daily",
|
||||||
|
dialect: "sqlite",
|
||||||
|
want: "substr(created_at, 1, 10)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sqlite hourly",
|
||||||
|
dialect: "sqlite",
|
||||||
|
hourly: true,
|
||||||
|
want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres daily",
|
||||||
|
dialect: "postgres",
|
||||||
|
want: "TO_CHAR(created_at, 'YYYY-MM-DD')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres hourly",
|
||||||
|
dialect: "postgres",
|
||||||
|
hourly: true,
|
||||||
|
want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("statsBucketExprForDialect error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("bucket expr = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) {
|
||||||
|
if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil {
|
||||||
|
t.Fatal("expected unsupported mysql dialect error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
@@ -874,6 +875,164 @@ func TestAdminDashboard_AllRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
yesterday := time.Now().AddDate(0, 0, -1)
|
||||||
|
createdAt := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 10, 0, 0, 0, time.Local)
|
||||||
|
updatedAt := createdAt.Add(5 * time.Minute)
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-dashboard-yesterday",
|
||||||
|
OrderId: "order-dashboard-yesterday",
|
||||||
|
Amount: 100,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 12.5,
|
||||||
|
ReceiveAddress: "TTestDashboardAddress001",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create historical order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
|
||||||
|
"created_at": createdAt,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("age historical order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
todayResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats", token))
|
||||||
|
todayData, _ := todayResp["data"].(map[string]interface{})
|
||||||
|
if got, _ := todayData["order_count"].(float64); got != 0 {
|
||||||
|
t.Fatalf("default order-stats order_count = %v, want 0 for today's range", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
statsResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats?range=7d", token))
|
||||||
|
statsData, _ := statsResp["data"].(map[string]interface{})
|
||||||
|
if got, _ := statsData["order_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("7d order-stats order_count = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got, _ := statsData["success_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("7d order-stats success_count = %v, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
overviewResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/overview?range=7d", token))
|
||||||
|
overviewData, _ := overviewResp["data"].(map[string]interface{})
|
||||||
|
if got, _ := overviewData["order_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("7d overview order_count = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got, _ := overviewData["volume"].(float64); got != 12.5 {
|
||||||
|
t.Fatalf("7d overview volume = %v, want 12.5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/admin/api/v1/dashboard/asset-trend?range=7d",
|
||||||
|
"/admin/api/v1/dashboard/revenue-trend?range=7d",
|
||||||
|
"/admin/api/v1/dashboard/asset-trend?range=30d",
|
||||||
|
"/admin/api/v1/dashboard/revenue-trend?range=30d",
|
||||||
|
} {
|
||||||
|
resp := assertOK(t, doGetAdmin(e, path, token))
|
||||||
|
rows, _ := resp["data"].([]interface{})
|
||||||
|
day := createdAt.Format("2006-01-02")
|
||||||
|
found := false
|
||||||
|
for _, row := range rows {
|
||||||
|
item, _ := row.(map[string]interface{})
|
||||||
|
if item["day"] != day {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if got, _ := item["order_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("%s order_count for %s = %v, want 1", path, day, got)
|
||||||
|
}
|
||||||
|
if got, _ := item["success_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("%s success_count for %s = %v, want 1", path, day, got)
|
||||||
|
}
|
||||||
|
if got, _ := item["actual_amount"].(float64); got != 12.5 {
|
||||||
|
t.Fatalf("%s actual_amount for %s = %v, want 12.5", path, day, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("%s missing historical bucket %s: %#v", path, day, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addressResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/asset-trend?range=7d&group_by=address", token))
|
||||||
|
addressRows, _ := addressResp["data"].([]interface{})
|
||||||
|
day := createdAt.Format("2006-01-02")
|
||||||
|
foundAddress := false
|
||||||
|
for _, row := range addressRows {
|
||||||
|
item, _ := row.(map[string]interface{})
|
||||||
|
if item["day"] != day || item["address"] != order.ReceiveAddress {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
foundAddress = true
|
||||||
|
if got, _ := item["actual_amount"].(float64); got != 12.5 {
|
||||||
|
t.Fatalf("address trend actual_amount for %s = %v, want 12.5", day, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundAddress {
|
||||||
|
t.Fatalf("address trend missing historical bucket %s/%s: %#v", day, order.ReceiveAddress, addressRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminDashboard_TodayTrendUsesHourlyBuckets(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
now := time.Now()
|
||||||
|
createdAt := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local)
|
||||||
|
updatedAt := createdAt.Add(2 * time.Minute)
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-dashboard-today",
|
||||||
|
OrderId: "order-dashboard-today",
|
||||||
|
Amount: 80,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 9.75,
|
||||||
|
ReceiveAddress: "TTestDashboardAddressHourly",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create today order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
|
||||||
|
"created_at": createdAt,
|
||||||
|
"updated_at": updatedAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("set today order time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/admin/api/v1/dashboard/asset-trend?range=today",
|
||||||
|
"/admin/api/v1/dashboard/revenue-trend?range=today",
|
||||||
|
} {
|
||||||
|
resp := assertOK(t, doGetAdmin(e, path, token))
|
||||||
|
rows, _ := resp["data"].([]interface{})
|
||||||
|
hour := createdAt.Format("2006-01-02 15:00")
|
||||||
|
found := false
|
||||||
|
for _, row := range rows {
|
||||||
|
item, _ := row.(map[string]interface{})
|
||||||
|
if item["day"] != hour {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if got, _ := item["order_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("%s order_count for %s = %v, want 1", path, hour, got)
|
||||||
|
}
|
||||||
|
if got, _ := item["success_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("%s success_count for %s = %v, want 1", path, hour, got)
|
||||||
|
}
|
||||||
|
if got, _ := item["actual_amount"].(float64); got != 9.75 {
|
||||||
|
t.Fatalf("%s actual_amount for %s = %v, want 9.75", path, hour, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("%s missing hourly bucket %s: %#v", path, hour, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
|
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
|
||||||
|
|||||||
Reference in New Issue
Block a user