package store

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

func TestMigrationFromAuthenticationSchema(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "migration.db")
	raw, err := sql.Open("sqlite", path)
	if err != nil {
		t.Fatal(err)
	}
	for _, statement := range migrationStatements(1) {
		if _, err := raw.ExecContext(ctx, statement); err != nil {
			t.Fatalf("create v1 schema: %v", err)
		}
	}
	if _, err := raw.ExecContext(ctx, `
		INSERT INTO admins (id, username, password_hash, created_at, updated_at)
		VALUES (1, 'legacy-admin', X'0102', 100, 100)
	`); err != nil {
		t.Fatal(err)
	}
	if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 1`); err != nil {
		t.Fatal(err)
	}
	if err := raw.Close(); err != nil {
		t.Fatal(err)
	}

	database := openTestStore(t, path)
	admin, err := database.CurrentAdmin(ctx)
	if err != nil {
		t.Fatalf("legacy admin missing after migration: %v", err)
	}
	if admin.Username != "legacy-admin" || !bytes.Equal(admin.PasswordHash, []byte{1, 2}) {
		t.Fatalf("legacy admin changed during migration: %+v", admin)
	}
	var version int
	if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
		t.Fatal(err)
	}
	if version != schemaVersion {
		t.Fatalf("schema version = %d, want %d", version, schemaVersion)
	}
	for _, table := range []string{
		"devices", "device_runtime", "vowifi_runtime", "sms_messages",
		"local_proxy_config", "upstream_proxies", "country_rules",
		"device_proxy_bindings",
		"notification_settings", "app_settings", "audit_events",
		"log_events", "card_policies", "traffic_buckets",
	} {
		var found string
		err := database.db.QueryRowContext(ctx, `
			SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?
		`, table).Scan(&found)
		if err != nil || found != table {
			t.Fatalf("migrated table %q missing: %v", table, err)
		}
	}
}

func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "ims-redelivery.db")
	legacy := openTestStore(t, path)
	mustSaveDevice(t, legacy, "ec20-1", "EC20")
	smscTime := time.Unix(1_700_000_000, 0).UTC()
	firstReceipt := smscTime.Add(2 * time.Hour)
	rawTPDU := "040ED0D637396C7EBBCB000062808051715140"
	for index, receivedAt := range []time.Time{firstReceipt, firstReceipt.Add(30 * time.Minute)} {
		extra, err := json.Marshal(map[string]any{"raw_tpdu": rawTPDU, "call_id": index})
		if err != nil {
			t.Fatal(err)
		}
		if _, err := legacy.SaveSMSMessage(ctx, SMSMessage{
			MessageID: fmt.Sprintf("legacy-call-%d", index),
			DeviceID:  "ec20-1",
			Peer:      "Vodafone",
			Direction: "inbound",
			Body:      "same message",
			Timestamp: smscTime,
			Status:    "received",
			Source:    "ims",
			CreatedAt: receivedAt,
			Extra:     extra,
		}); err != nil {
			t.Fatal(err)
		}
	}
	if _, err := legacy.db.ExecContext(ctx, `PRAGMA user_version = 3`); err != nil {
		t.Fatal(err)
	}
	if err := legacy.Close(); err != nil {
		t.Fatal(err)
	}

	migrated := openTestStore(t, path)
	messages, err := migrated.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"})
	if err != nil {
		t.Fatal(err)
	}
	if len(messages) != 2 {
		t.Fatalf("message count after migration = %d, want 2", len(messages))
	}
	if !messages[0].Timestamp.Equal(firstReceipt.Add(30*time.Minute)) ||
		!messages[1].Timestamp.Equal(firstReceipt) {
		t.Fatalf("message times = %v / %v, want both receipt times", messages[0].Timestamp, messages[1].Timestamp)
	}
	var extra map[string]any
	if err := json.Unmarshal(messages[0].Extra, &extra); err != nil {
		t.Fatal(err)
	}
	if extra["service_center_timestamp_unix"] != float64(smscTime.Unix()) {
		t.Fatalf("service center time was not retained: %#v", extra)
	}
}

func TestDeviceStateRoundTripAndCascade(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	rsrp, rsrq, sinr := -95, -12, 15
	attached, inserted := true, true
	mode := 1
	device := Device{
		ID:             "ec20-1",
		Name:           "EC20 一号",
		Interface:      "wwan0",
		ControlDevice:  "/dev/cdc-wdm0",
		ATPort:         "/dev/ttyUSB2",
		APN:            "ims",
		ProxyPort:      1080,
		QMIUseProxy:    true,
		NetworkEnabled: true,
		SMSEnabled:     true,
		VoWiFiEnabled:  true,
		Extra:          json.RawMessage(`{"slot":1}`),
	}
	runtime := DeviceRuntime{
		Running:           true,
		Healthy:           true,
		ControlOnline:     true,
		NetworkConnected:  true,
		Operator:          "China Mobile",
		SignalDBM:         -71,
		SignalRSRP:        &rsrp,
		SignalRSRQ:        &rsrq,
		SignalSINR:        &sinr,
		ICCID:             "8986000000000000000",
		IMSI:              "460001234567890",
		PSAttached:        &attached,
		SIMInserted:       &inserted,
		OperatingMode:     &mode,
		PhoneNumber:       "+8613800138000",
		PhoneNumberSource: "cnum",
		Traffic:           json.RawMessage(`{"rx":"1 MiB"}`),
	}
	vowifi := VoWiFiRuntime{
		Phase:             "sms_ready",
		SIMReady:          true,
		AccessReady:       true,
		TunnelReady:       true,
		IMSReady:          true,
		SMSReady:          true,
		LocalPhone:        "+8613800138000",
		PhoneNumberSource: "ims",
		Tunnel:            json.RawMessage(`{"ifname":"ipsec0"}`),
	}
	if err := database.SaveDeviceState(ctx, device, &runtime, &vowifi); err != nil {
		t.Fatalf("SaveDeviceState() error = %v", err)
	}

	gotDevice, err := database.Device(ctx, device.ID)
	if err != nil {
		t.Fatal(err)
	}
	if gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 ||
		gotDevice.StopBits != 1 || gotDevice.DeviceBackend != "at" {
		t.Fatalf("device defaults not applied: %+v", gotDevice)
	}
	gotRuntime, err := database.DeviceRuntime(ctx, device.ID)
	if err != nil {
		t.Fatal(err)
	}
	if gotRuntime.PhoneNumber != runtime.PhoneNumber ||
		gotRuntime.SignalRSRP == nil || *gotRuntime.SignalRSRP != rsrp ||
		gotRuntime.PSAttached == nil || !*gotRuntime.PSAttached {
		t.Fatalf("runtime did not round trip: %+v", gotRuntime)
	}
	gotVoWiFi, err := database.VoWiFiRuntime(ctx, device.ID)
	if err != nil {
		t.Fatal(err)
	}
	if !gotVoWiFi.SMSReady || gotVoWiFi.LocalPhone != vowifi.LocalPhone {
		t.Fatalf("VoWiFi runtime did not round trip: %+v", gotVoWiFi)
	}
	if err := database.DeleteDevice(ctx, device.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := database.DeviceRuntime(ctx, device.ID); !errors.Is(err, ErrNotFound) {
		t.Fatalf("runtime should cascade on device deletion, got %v", err)
	}
	if _, err := database.VoWiFiRuntime(ctx, device.ID); !errors.Is(err, ErrNotFound) {
		t.Fatalf("VoWiFi runtime should cascade on device deletion, got %v", err)
	}
}

func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	mustSaveDevice(t, database, "ec20-1", "客厅")
	if err := database.UpsertDeviceRuntime(ctx, DeviceRuntime{
		DeviceID:    "ec20-1",
		PhoneNumber: "+8613800138000",
	}); err != nil {
		t.Fatal(err)
	}
	base := time.Unix(1_700_000_000, 0).UTC()
	if err := database.SaveSMSMessages(ctx, []SMSMessage{
		{
			MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
			Peer: "10086", Direction: "inbound", Body: "第一条",
			Timestamp: base, Status: "received",
		},
		{
			MessageID: "network-2", DeviceID: "ec20-1", IMSI: "46000",
			Peer: "10086", Direction: "outbound", Body: "第二条",
			Timestamp: base.Add(time.Minute), Status: "sent", Read: true,
		},
		{
			MessageID: "network-3", DeviceID: "ec20-1", IMSI: "46000",
			Peer: "95533", Direction: "received", Body: "银行提醒",
			Timestamp: base.Add(2 * time.Minute), Status: "received",
		},
	}); err != nil {
		t.Fatalf("SaveSMSMessages() error = %v", err)
	}

	// A modem retry updates the stable external id instead of duplicating it.
	if _, err := database.SaveSMSMessage(ctx, SMSMessage{
		MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
		Peer: "10086", Direction: "inbound", Body: "第一条（完整）",
		Timestamp: base, Status: "received",
	}); err != nil {
		t.Fatal(err)
	}
	messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"})
	if err != nil {
		t.Fatal(err)
	}
	if len(messages) != 3 {
		t.Fatalf("message count = %d, want 3", len(messages))
	}
	if !messages[2].Timestamp.Equal(base) {
		t.Fatalf("retry changed the original message time to %v", messages[2].Timestamp)
	}
	contacts, err := database.ListSMSContacts(ctx, SMSFilter{DeviceID: "ec20-1"})
	if err != nil {
		t.Fatal(err)
	}
	if len(contacts) != 2 || contacts[0].Peer != "95533" ||
		contacts[0].UnreadCount != 1 || contacts[1].Peer != "10086" ||
		contacts[1].MessageCount != 2 || contacts[1].UnreadCount != 1 ||
		contacts[1].LocalPhone != "+8613800138000" {
		t.Fatalf("unexpected derived contacts: %+v", contacts)
	}
	marked, err := database.MarkSMSThreadRead(ctx, "ec20-1", "46000", "10086")
	if err != nil || marked != 1 {
		t.Fatalf("MarkSMSThreadRead() = %d, %v", marked, err)
	}
	contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
	if err != nil {
		t.Fatal(err)
	}
	if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
		t.Fatalf("thread should be read: %+v", contacts)
	}
	deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
	if err != nil || deleted != 2 {
		t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
	}
}

func TestListInboundSMSAfterIDUsesDurableInsertionCursor(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	mustSaveDevice(t, database, "ec20-1", "EC20")
	old, err := database.SaveSMSMessage(ctx, SMSMessage{
		MessageID: "old-inbound", DeviceID: "ec20-1", Peer: "10086",
		Direction: "inbound", Body: "old", Status: "received",
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := database.SaveSMSMessage(ctx, SMSMessage{
		MessageID: "new-outbound", DeviceID: "ec20-1", Peer: "10010",
		Direction: "outbound", Body: "sent", Status: "sent",
	}); err != nil {
		t.Fatal(err)
	}
	newInbound, err := database.SaveSMSMessage(ctx, SMSMessage{
		MessageID: "new-inbound", DeviceID: "ec20-1", Peer: "95533",
		Direction: "received", Body: "new", Status: "received",
	})
	if err != nil {
		t.Fatal(err)
	}
	latest, err := database.LatestSMSMessageID(ctx)
	if err != nil || latest != newInbound.ID {
		t.Fatalf("LatestSMSMessageID() = %d, %v; want %d", latest, err, newInbound.ID)
	}
	messages, err := database.ListInboundSMSAfterID(ctx, old.ID, 100)
	if err != nil {
		t.Fatal(err)
	}
	if len(messages) != 1 || messages[0].ID != newInbound.ID {
		t.Fatalf("ListInboundSMSAfterID() = %#v", messages)
	}
}

func TestApplySMSDeliveryReportTracksEverySubmittedPart(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	mustSaveDevice(t, database, "ec20-1", "EC20")
	extra := json.RawMessage(`{
		"transport":"ims",
		"part_results":[{"reference":42},{"reference":43}]
	}`)
	sent, err := database.SaveSMSMessage(ctx, SMSMessage{
		MessageID: "ims-submit-1", DeviceID: "ec20-1", IMSI: "23415",
		Peer: "+447700900123", Direction: "outbound", Body: "multipart",
		Timestamp: time.Now().UTC(), Status: "accepted_by_ims", Source: "ims",
		PartsTotal: 2, DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
	})
	if err != nil {
		t.Fatal(err)
	}
	first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
		DeviceID: "ec20-1", IMSI: "23415", Peer: "+447700900123", Source: "ims",
		MessageReference: 42, StatusCode: 0, DeliveryState: "delivered",
	})
	if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
		t.Fatalf("first delivery report = (%#v, %v)", first, err)
	}
	second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
		DeviceID: "ec20-1", IMSI: "23415", Peer: "+447700900123", Source: "ims",
		MessageReference: 43, StatusCode: 0, DeliveryState: "delivered",
	})
	if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
		t.Fatalf("second delivery report = (%#v, %v)", second, err)
	}
	var savedExtra map[string]any
	if err := json.Unmarshal(second.Extra, &savedExtra); err != nil {
		t.Fatal(err)
	}
	reports, _ := savedExtra["delivery_reports"].(map[string]any)
	if len(reports) != 2 {
		t.Fatalf("delivery reports = %#v", reports)
	}
}

func TestProxyCredentialsAndCountryRules(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	mustSaveDevice(t, database, "ec20-1", "EC20")
	if err := database.UpsertLocalProxy(ctx, LocalProxyConfig{
		ID: "local-1", Name: "SOCKS", Mode: "socks5", DeviceID: "ec20-1",
		ListenAddr: "127.0.0.1", ListenPort: 1080, Enabled: true,
		AuthEnabled: true, Username: "user", Password: "local-secret",
	}); err != nil {
		t.Fatal(err)
	}
	if err := database.UpsertLocalProxy(ctx, LocalProxyConfig{
		ID: "local-1", Name: "SOCKS 新", Mode: "socks5", DeviceID: "ec20-1",
		ListenAddr: "127.0.0.1", ListenPort: 1080, Enabled: true,
		AuthEnabled: true, Username: "user", Password: "",
	}); err != nil {
		t.Fatal(err)
	}
	local, err := database.LocalProxy(ctx, "local-1")
	if err != nil {
		t.Fatal(err)
	}
	if local.Password != "local-secret" || local.Redacted().Password != SecretMask ||
		local.Public().Password != "" {
		t.Fatalf("local proxy credential semantics failed: %+v", local)
	}

	if err := database.UpsertUpstreamProxy(ctx, UpstreamProxy{
		ID: "up-1", Name: "上游", Addr: "127.0.0.1:2080",
		Username: "up-user", Password: "up-secret", Enabled: true,
	}); err != nil {
		t.Fatal(err)
	}
	if err := database.UpsertUpstreamProxy(ctx, UpstreamProxy{
		ID: "up-1", Name: "上游新", Addr: "127.0.0.1:2080",
		Username: "up-user", Password: SecretMask, Enabled: true,
	}); err != nil {
		t.Fatal(err)
	}
	upstream, err := database.UpstreamProxy(ctx, "up-1")
	if err != nil {
		t.Fatal(err)
	}
	if upstream.Password != "up-secret" {
		t.Fatalf("blank/masked update erased upstream secret: %+v", upstream)
	}
	if got := RedactText(
		"connect local-secret through up-secret",
		local,
		upstream,
	); strings.Contains(got, "secret") {
		t.Fatalf("RedactText leaked credentials: %q", got)
	}
	if err := database.UpsertCountryRule(ctx, CountryRule{
		CountryCode: "cn", CountryName: "中国", UpstreamProxyID: "up-1",
		Enabled: true,
	}); err != nil {
		t.Fatal(err)
	}
	rule, err := database.CountryRule(ctx, "CN")
	if err != nil || rule.CountryCode != "CN" {
		t.Fatalf("CountryRule() = %+v, %v", rule, err)
	}
	if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
		DeviceID: "ec20-1", UpstreamProxyID: "up-1",
	}); err != nil {
		t.Fatal(err)
	}
	binding, err := database.DeviceProxyBinding(ctx, "ec20-1")
	if err != nil || binding.UpstreamProxyID != "up-1" {
		t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
	}
	if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil {
		t.Fatal(err)
	}
	if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
	}
	if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
	}
}

func TestNotificationAndAppSecretPreservation(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	if err := database.SaveNotificationSettings(ctx, []NotificationSetting{
		{
			Channel: "email",
			Config:  json.RawMessage(`{"password":"mail-secret"}`),
		},
		{
			Channel: "webhook",
			Config:  json.RawMessage(`not-json`),
		},
	}); err == nil {
		t.Fatal("invalid notification batch was accepted")
	}
	if _, err := database.NotificationSetting(ctx, "email"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("notification batch was not rolled back: %v", err)
	}
	if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
		Channel: "telegram",
		Enabled: true,
		Config:  json.RawMessage(`{"bot_token":"telegram-secret","chat_id":"1"}`),
	}); err != nil {
		t.Fatal(err)
	}
	if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
		Channel: "telegram",
		Enabled: true,
		Config:  json.RawMessage(`{"bot_token":"","chat_id":"2"}`),
	}); err != nil {
		t.Fatal(err)
	}
	setting, err := database.NotificationSetting(ctx, "telegram")
	if err != nil {
		t.Fatal(err)
	}
	var config map[string]any
	if err := json.Unmarshal(setting.Config, &config); err != nil {
		t.Fatal(err)
	}
	if config["bot_token"] != "telegram-secret" || config["chat_id"] != "2" {
		t.Fatalf("notification merge lost data: %s", setting.Config)
	}
	if bytes.Contains(setting.Redacted().Config, []byte("telegram-secret")) ||
		bytes.Contains(setting.Public().Config, []byte("telegram-secret")) {
		t.Fatal("notification views leaked secret")
	}
	if got := RedactText("token=telegram-secret", setting); strings.Contains(got, "telegram-secret") {
		t.Fatalf("notification secret leaked in text: %q", got)
	}

	if err := database.UpsertAppSetting(ctx, AppSetting{
		Key: "provider.token", Value: json.RawMessage(`"app-secret"`), Sensitive: true,
	}); err != nil {
		t.Fatal(err)
	}
	if err := database.UpsertAppSetting(ctx, AppSetting{
		Key: "provider.token", Value: json.RawMessage(`"********"`), Sensitive: true,
	}); err != nil {
		t.Fatal(err)
	}
	appSetting, err := database.AppSetting(ctx, "provider.token")
	if err != nil {
		t.Fatal(err)
	}
	if string(appSetting.Value) != `"app-secret"` ||
		string(appSetting.Redacted().Value) != `"********"` ||
		string(appSetting.Public().Value) != `null` {
		t.Fatalf("unexpected sensitive app setting: %+v", appSetting)
	}
}

func TestEventsPoliciesAndTraffic(t *testing.T) {
	ctx := context.Background()
	database := openTestStore(t, ":memory:")
	old := time.Unix(1_700_000_000, 0).UTC()
	recent := old.Add(time.Hour)
	if _, err := database.AppendAuditEvent(ctx, AuditEvent{
		Actor: "admin", Action: "device.update", EntityType: "device",
		EntityID: "ec20-1", Outcome: "ok", CreatedAt: old,
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := database.AppendAuditEvent(ctx, AuditEvent{
		Actor: "system", Action: "device.refresh", EntityType: "device",
		EntityID: "ec20-1", Outcome: "ok", CreatedAt: recent,
	}); err != nil {
		t.Fatal(err)
	}
	audits, err := database.ListAuditEvents(ctx, AuditFilter{Actor: "admin"})
	if err != nil || len(audits) != 1 || audits[0].Action != "device.update" {
		t.Fatalf("audit filter result = %+v, %v", audits, err)
	}
	if _, err := database.AppendLogEvent(ctx, LogEvent{
		Time: old, Level: "warn", Message: "old warning",
		Fields: json.RawMessage(`{"device":"ec20-1"}`),
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := database.AppendLogEvent(ctx, LogEvent{
		Time: recent, Level: "info", Message: "ready",
	}); err != nil {
		t.Fatal(err)
	}
	logs, err := database.ListLogEvents(ctx, LogFilter{Level: "info"})
	if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
		t.Fatalf("log filter result = %+v, %v", logs, err)
	}
	auditDeleted, logDeleted, err := database.PruneEvents(
		ctx,
		old.Add(time.Minute),
		old.Add(time.Minute),
	)
	if err != nil || auditDeleted != 1 || logDeleted != 1 {
		t.Fatalf("PruneEvents() = %d, %d, %v", auditDeleted, logDeleted, err)
	}

	if err := database.UpsertCardPolicy(ctx, CardPolicy{
		ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
		APN: "ims", IPVersion: "ipv4v6",
	}); err != nil {
		t.Fatal(err)
	}
	if err := database.UpsertCardPolicy(ctx, CardPolicy{
		ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true,
	}); err == nil {
		t.Fatal("invalid mutually exclusive card policy was accepted")
	}
	policy, err := database.CardPolicy(ctx, "89860001")
	if err != nil || !policy.VoWiFiEnabled {
		t.Fatalf("CardPolicy() = %+v, %v", policy, err)
	}

	period := old.Truncate(time.Hour)
	if err := database.UpsertTrafficBucket(ctx, TrafficBucket{
		DeviceID: "ec20-1", Bucket: "hour", PeriodStart: period,
		RXBytes: 100, TXBytes: 25,
	}); err != nil {
		t.Fatal(err)
	}
	if err := database.AddTrafficBucket(ctx, TrafficBucket{
		DeviceID: "ec20-1", Bucket: "hour", PeriodStart: period,
		RXBytes: 5, TXBytes: 10,
	}); err != nil {
		t.Fatal(err)
	}
	buckets, err := database.ListTrafficBuckets(ctx, TrafficFilter{
		DeviceID: "ec20-1", Bucket: "hour",
	})
	if err != nil || len(buckets) != 1 ||
		buckets[0].RXBytes != 105 || buckets[0].TXBytes != 35 ||
		buckets[0].TotalBytes() != 140 {
		t.Fatalf("traffic buckets = %+v, %v", buckets, err)
	}
}

func openTestStore(t *testing.T, path string) *Store {
	t.Helper()
	database, err := Open(context.Background(), path)
	if err != nil {
		t.Fatalf("Open(%q) error = %v", path, err)
	}
	t.Cleanup(func() {
		if err := database.Close(); err != nil {
			t.Errorf("Close() error = %v", err)
		}
	})
	return database
}

func mustSaveDevice(t *testing.T, database *Store, id, name string) {
	t.Helper()
	if err := database.UpsertDevice(context.Background(), Device{
		ID: id, Name: name, SMSEnabled: true,
	}); err != nil {
		t.Fatalf("UpsertDevice() error = %v", err)
	}
}
