303 lines
9.2 KiB
Go
303 lines
9.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"billing-service/internal/config"
|
|
"billing-service/internal/model"
|
|
"billing-service/internal/repository"
|
|
)
|
|
|
|
type fakeSource struct {
|
|
snapshot model.Snapshot
|
|
err error
|
|
}
|
|
|
|
func (f fakeSource) FetchLatestSnapshot(context.Context) (model.Snapshot, error) {
|
|
return f.snapshot, f.err
|
|
}
|
|
|
|
type memoryRepo struct {
|
|
checkpoints map[string]model.Checkpoint
|
|
buckets map[string]model.MinuteBucket
|
|
ledgers map[string]model.LedgerEntry
|
|
quotas map[string]model.QuotaState
|
|
}
|
|
|
|
func newMemoryRepo() *memoryRepo {
|
|
return &memoryRepo{
|
|
checkpoints: map[string]model.Checkpoint{},
|
|
buckets: map[string]model.MinuteBucket{},
|
|
ledgers: map[string]model.LedgerEntry{},
|
|
quotas: map[string]model.QuotaState{},
|
|
}
|
|
}
|
|
|
|
func checkpointKey(nodeID, accountUUID string) string {
|
|
return nodeID + "\x00" + accountUUID
|
|
}
|
|
|
|
func bucketKey(bucket model.MinuteBucket) string {
|
|
return bucket.BucketStart.UTC().Format(time.RFC3339) + "\x00" + bucket.NodeID + "\x00" + bucket.AccountUUID + "\x00" + bucket.Region + "\x00" + bucket.LineCode
|
|
}
|
|
|
|
func (m *memoryRepo) GetCheckpoint(ctx context.Context, nodeID, accountUUID string) (*model.Checkpoint, error) {
|
|
if checkpoint, ok := m.checkpoints[checkpointKey(nodeID, accountUUID)]; ok {
|
|
copy := checkpoint
|
|
return ©, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *memoryRepo) UpsertCheckpoint(ctx context.Context, checkpoint model.Checkpoint) error {
|
|
m.checkpoints[checkpointKey(checkpoint.NodeID, checkpoint.AccountUUID)] = checkpoint
|
|
return nil
|
|
}
|
|
|
|
func (m *memoryRepo) UpsertMinuteBucket(ctx context.Context, bucket model.MinuteBucket) (bool, error) {
|
|
key := bucketKey(bucket)
|
|
_, existed := m.buckets[key]
|
|
m.buckets[key] = bucket
|
|
return existed, nil
|
|
}
|
|
|
|
func (m *memoryRepo) UpsertLedger(ctx context.Context, entry model.LedgerEntry) (bool, error) {
|
|
_, existed := m.ledgers[entry.ID]
|
|
m.ledgers[entry.ID] = entry
|
|
return existed, nil
|
|
}
|
|
|
|
func (m *memoryRepo) GetQuotaState(ctx context.Context, accountUUID string) (*model.QuotaState, error) {
|
|
if quota, ok := m.quotas[accountUUID]; ok {
|
|
copy := quota
|
|
return ©, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *memoryRepo) UpsertQuotaState(ctx context.Context, state model.QuotaState) error {
|
|
m.quotas[state.AccountUUID] = state
|
|
return nil
|
|
}
|
|
|
|
var _ repository.Repository = (*memoryRepo)(nil)
|
|
|
|
func baseConfig() config.Config {
|
|
return config.Config{
|
|
DefaultRegion: "",
|
|
SourceRevision: "billing-service-v1",
|
|
PricePerByte: 0.5,
|
|
InitialIncludedQuotaBytes: 1000,
|
|
InitialBalance: 0,
|
|
}
|
|
}
|
|
|
|
func TestDeltaCalculationAndQuotaUpdate(t *testing.T) {
|
|
repo := newMemoryRepo()
|
|
svc := New(baseConfig(), fakeSource{snapshot: model.Snapshot{
|
|
CollectedAt: time.Date(2026, 4, 8, 10, 30, 15, 0, time.UTC),
|
|
NodeID: "jp-node",
|
|
Env: "prod",
|
|
Samples: []model.Sample{{
|
|
UUID: "11111111-1111-1111-1111-111111111111",
|
|
Email: "user@example.com",
|
|
InboundTag: "premium",
|
|
UplinkBytesTotal: 100,
|
|
DownlinkBytesTotal: 50,
|
|
}},
|
|
}}, repo)
|
|
|
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
|
if err != nil {
|
|
t.Fatalf("run job: %v", err)
|
|
}
|
|
if result.ProcessedSamples != 1 || result.WrittenMinutes != 1 {
|
|
t.Fatalf("unexpected result %#v", result)
|
|
}
|
|
quota := repo.quotas["11111111-1111-1111-1111-111111111111"]
|
|
if quota.RemainingIncludedQuota != 850 {
|
|
t.Fatalf("expected remaining quota 850, got %d", quota.RemainingIncludedQuota)
|
|
}
|
|
if quota.CurrentBalance != -75 {
|
|
t.Fatalf("expected current balance -75, got %v", quota.CurrentBalance)
|
|
}
|
|
}
|
|
|
|
func TestDuplicateMinuteIsReplaySafe(t *testing.T) {
|
|
repo := newMemoryRepo()
|
|
snapshot := model.Snapshot{
|
|
CollectedAt: time.Date(2026, 4, 8, 10, 30, 30, 0, time.UTC),
|
|
NodeID: "jp-node",
|
|
Env: "prod",
|
|
Samples: []model.Sample{{
|
|
UUID: "11111111-1111-1111-1111-111111111111",
|
|
Email: "user@example.com",
|
|
InboundTag: "premium",
|
|
UplinkBytesTotal: 100,
|
|
DownlinkBytesTotal: 50,
|
|
}},
|
|
}
|
|
svc := New(baseConfig(), fakeSource{snapshot: snapshot}, repo)
|
|
|
|
if _, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
|
t.Fatalf("first run: %v", err)
|
|
}
|
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
|
if err != nil {
|
|
t.Fatalf("second run: %v", err)
|
|
}
|
|
if result.ReplayedMinutes == 0 {
|
|
t.Fatalf("expected replayed minutes, got %#v", result)
|
|
}
|
|
if len(repo.ledgers) != 1 {
|
|
t.Fatalf("expected 1 ledger entry, got %d", len(repo.ledgers))
|
|
}
|
|
}
|
|
|
|
func TestNegativeDeltaProtection(t *testing.T) {
|
|
repo := newMemoryRepo()
|
|
cfg := baseConfig()
|
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
|
nodeKey := composeStorageNodeID("prod", "jp-node")
|
|
repo.checkpoints[checkpointKey(nodeKey, accountUUID)] = model.Checkpoint{
|
|
NodeID: nodeKey,
|
|
AccountUUID: accountUUID,
|
|
LastUplinkTotal: 200,
|
|
LastDownlinkTotal: 200,
|
|
LastSeenAt: time.Now().UTC(),
|
|
XrayRevision: "prev",
|
|
ResetEpoch: 0,
|
|
}
|
|
svc := New(cfg, fakeSource{snapshot: model.Snapshot{
|
|
CollectedAt: time.Date(2026, 4, 8, 10, 31, 0, 0, time.UTC),
|
|
NodeID: "jp-node",
|
|
Env: "prod",
|
|
Samples: []model.Sample{{
|
|
UUID: accountUUID,
|
|
InboundTag: "premium",
|
|
UplinkBytesTotal: 10,
|
|
DownlinkBytesTotal: 20,
|
|
}},
|
|
}}, repo)
|
|
|
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
|
if err != nil {
|
|
t.Fatalf("run job: %v", err)
|
|
}
|
|
if result.ProcessedSamples != 0 {
|
|
t.Fatalf("expected negative delta sample to be skipped, got %#v", result)
|
|
}
|
|
if len(repo.buckets) != 0 || len(repo.ledgers) != 0 {
|
|
t.Fatalf("expected no writes on negative delta")
|
|
}
|
|
if repo.checkpoints[checkpointKey(nodeKey, accountUUID)].ResetEpoch != 1 {
|
|
t.Fatalf("expected reset epoch increment")
|
|
}
|
|
}
|
|
|
|
func TestRestartRecoveryFromCheckpoint(t *testing.T) {
|
|
repo := newMemoryRepo()
|
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
|
nodeKey := composeStorageNodeID("prod", "jp-node")
|
|
repo.checkpoints[checkpointKey(nodeKey, accountUUID)] = model.Checkpoint{
|
|
NodeID: nodeKey,
|
|
AccountUUID: accountUUID,
|
|
LastUplinkTotal: 100,
|
|
LastDownlinkTotal: 100,
|
|
LastSeenAt: time.Now().UTC(),
|
|
}
|
|
svc := New(baseConfig(), fakeSource{snapshot: model.Snapshot{
|
|
CollectedAt: time.Date(2026, 4, 8, 10, 32, 0, 0, time.UTC),
|
|
NodeID: "jp-node",
|
|
Env: "prod",
|
|
Samples: []model.Sample{{
|
|
UUID: accountUUID,
|
|
InboundTag: "premium",
|
|
UplinkBytesTotal: 130,
|
|
DownlinkBytesTotal: 140,
|
|
}},
|
|
}}, repo)
|
|
|
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
|
if err != nil {
|
|
t.Fatalf("run job: %v", err)
|
|
}
|
|
if result.ProcessedSamples != 1 || result.WrittenMinutes != 1 {
|
|
t.Fatalf("unexpected result %#v", result)
|
|
}
|
|
bucket := repo.buckets[bucketKey(model.MinuteBucket{
|
|
BucketStart: time.Date(2026, 4, 8, 10, 32, 0, 0, time.UTC),
|
|
NodeID: nodeKey,
|
|
AccountUUID: accountUUID,
|
|
Region: "",
|
|
LineCode: "premium",
|
|
})]
|
|
if bucket.TotalBytes != 70 {
|
|
t.Fatalf("expected recovered delta 70, got %d", bucket.TotalBytes)
|
|
}
|
|
}
|
|
|
|
func TestMultiEnvIsolation(t *testing.T) {
|
|
repo := newMemoryRepo()
|
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
|
cfg := baseConfig()
|
|
|
|
prodSvc := New(cfg, fakeSource{snapshot: model.Snapshot{
|
|
CollectedAt: time.Date(2026, 4, 8, 10, 33, 0, 0, time.UTC),
|
|
NodeID: "jp-node",
|
|
Env: "prod",
|
|
Samples: []model.Sample{{UUID: accountUUID, InboundTag: "premium", UplinkBytesTotal: 10, DownlinkBytesTotal: 10}},
|
|
}}, repo)
|
|
previewSvc := New(cfg, fakeSource{snapshot: model.Snapshot{
|
|
CollectedAt: time.Date(2026, 4, 8, 10, 33, 0, 0, time.UTC),
|
|
NodeID: "jp-node",
|
|
Env: "preview",
|
|
Samples: []model.Sample{{UUID: accountUUID, InboundTag: "premium", UplinkBytesTotal: 10, DownlinkBytesTotal: 10}},
|
|
}}, repo)
|
|
|
|
if _, err := prodSvc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
|
t.Fatalf("prod run: %v", err)
|
|
}
|
|
if _, err := previewSvc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
|
t.Fatalf("preview run: %v", err)
|
|
}
|
|
if len(repo.buckets) != 2 {
|
|
t.Fatalf("expected isolated buckets per env, got %d", len(repo.buckets))
|
|
}
|
|
}
|
|
|
|
func TestLateMinuteReconcileUsesSameMinuteKey(t *testing.T) {
|
|
repo := newMemoryRepo()
|
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
|
cfg := baseConfig()
|
|
collectedAt := time.Date(2026, 4, 8, 10, 34, 50, 0, time.UTC)
|
|
snapshot := model.Snapshot{
|
|
CollectedAt: collectedAt,
|
|
NodeID: "jp-node",
|
|
Env: "prod",
|
|
Samples: []model.Sample{{
|
|
UUID: accountUUID,
|
|
InboundTag: "premium",
|
|
UplinkBytesTotal: 20,
|
|
DownlinkBytesTotal: 20,
|
|
}},
|
|
}
|
|
svc := New(cfg, fakeSource{snapshot: snapshot}, repo)
|
|
|
|
if _, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
|
t.Fatalf("first run: %v", err)
|
|
}
|
|
result, err := svc.RunCollectAndRate(context.Background(), "reconcile")
|
|
if err != nil {
|
|
t.Fatalf("reconcile run: %v", err)
|
|
}
|
|
if result.ReplayedMinutes == 0 {
|
|
t.Fatalf("expected reconcile to report replayed minute, got %#v", result)
|
|
}
|
|
if len(repo.buckets) != 1 {
|
|
t.Fatalf("expected single logical minute bucket, got %d", len(repo.buckets))
|
|
}
|
|
}
|