459 lines
18 KiB
Go
459 lines
18 KiB
Go
package management
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testServer(t *testing.T, apply bool) (*Server, *Store) {
|
|
t.Helper()
|
|
store, err := NewMemoryStore()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.MockCellApply = apply
|
|
server := NewServer(cfg, store)
|
|
if err := store.SeedDemo(context.Background()); err != nil {
|
|
store.Close()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
return server, store
|
|
}
|
|
|
|
func request(t *testing.T, server *Server, method, path, token, requestID, ifMatch string, body any) *http.Response {
|
|
t.Helper()
|
|
var data []byte
|
|
if body != nil {
|
|
data, _ = json.Marshal(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(data))
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
if requestID != "" {
|
|
req.Header.Set("X-Request-ID", requestID)
|
|
}
|
|
if ifMatch != "" {
|
|
req.Header.Set("If-Match", ifMatch)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := server.App().Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func responseJSON(t *testing.T, resp *http.Response) map[string]any {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
var body map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func TestAuthorizationAndIdempotency(t *testing.T) {
|
|
server, store := testServer(t, true)
|
|
resp := request(t, server, http.MethodGet, "/admin/v1/trunks", "dev-saas-token", "", "", nil)
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("saas token on admin API: %d", resp.StatusCode)
|
|
}
|
|
resp = request(t, server, http.MethodGet, "/readonly/v1/sip/trunks", "dev-saas-token", "", "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("readonly API: %d", resp.StatusCode)
|
|
}
|
|
resp = request(t, server, http.MethodGet, "/admin/v1/trunks/demo-trunk", "dev-admin-token", "", "", nil)
|
|
body := responseJSON(t, resp)
|
|
latest := int(body["latest_revision"].(float64))
|
|
input := TrunkConfig{ProviderID: "demo-provider", DisplayName: "更新线路", Enabled: true, Sip: SipConfig{Host: "198.51.100.21", Port: 5060, Transport: "udp", AuthMode: "ip"}, CodecProfile: CodecProfile{Allowed: []string{"PCMA"}, Preferred: "PCMA"}, CallerIDs: []string{"BD93205882"}, DialPrefix: "7089", EgressPoolID: "egress-main", MaxConcurrency: 100, MaxCPS: 5}
|
|
resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("put trunk: %d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
first := responseJSON(t, resp)
|
|
resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("idempotent retry: %d", resp.StatusCode)
|
|
}
|
|
second := responseJSON(t, resp)
|
|
if first["trunk"].(map[string]any)["latest_revision"] != second["trunk"].(map[string]any)["latest_revision"] {
|
|
t.Fatal("retry created a second revision")
|
|
}
|
|
if _, err := store.db.Exec("UPDATE operations SET expires_at=? WHERE request_id=?", utcString(time.Now().Add(-time.Hour)), "trunk-update-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expired idempotency status: %d", resp.StatusCode)
|
|
}
|
|
if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "IDEMPOTENCY_EXPIRED" {
|
|
t.Fatalf("expired idempotency code=%v", code)
|
|
}
|
|
input.DisplayName = "different payload"
|
|
resp = request(t, server, http.MethodPut, "/admin/v1/trunks/demo-trunk", "dev-config-token", "trunk-update-1", strconvI(latest), input)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("idempotency conflict: %d", resp.StatusCode)
|
|
}
|
|
resp = request(t, server, http.MethodGet, "/admin/v1/trunks/demo-trunk", "dev-admin-token", "", "", nil)
|
|
decoded := responseJSON(t, resp)
|
|
latestView := decoded["latest"].(map[string]any)
|
|
if _, ok := latestView["credential_ref"]; ok {
|
|
t.Fatal("credential reference leaked from read response")
|
|
}
|
|
}
|
|
|
|
func TestPublicationBarrierAndRecovery(t *testing.T) {
|
|
server, store := testServer(t, false)
|
|
cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.DisplayName = "待发布线路"
|
|
if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "fixture-draft"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "publish-intent-1", "2", nil)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("intent-only publish status: %d", resp.StatusCode)
|
|
}
|
|
if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "PUBLISH_PENDING" {
|
|
t.Fatalf("error code=%v", code)
|
|
}
|
|
trunk, _ := store.GetTrunk(context.Background(), "demo-trunk", ModeMock)
|
|
if trunk.ActiveRevision != 1 {
|
|
t.Fatalf("pending publication changed active revision: %d", trunk.ActiveRevision)
|
|
}
|
|
server.cfg.MockCellApply = true
|
|
resp = request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "publish-retry-1", "2", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("recovered publish: %d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
trunk, _ = store.GetTrunk(context.Background(), "demo-trunk", ModeMock)
|
|
if trunk.ActiveRevision != 2 || trunk.Status != "published" {
|
|
t.Fatalf("publication did not finalize: %+v", trunk)
|
|
}
|
|
}
|
|
|
|
func TestStatisticsSemantics(t *testing.T) {
|
|
server, _ := testServer(t, true)
|
|
resp := request(t, server, http.MethodGet, "/admin/v1/statistics/outbound/summary", "dev-admin-token", "", "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("summary: %d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
body := responseJSON(t, resp)
|
|
metrics := body["metrics"].(map[string]any)
|
|
for field, want := range map[string]float64{"attempts_total": 6, "started_confirmed": 6, "answered": 2, "terminal_not_answered": 3, "rejected": 1, "pending_not_answered": 1} {
|
|
if got := metrics[field].(float64); got != want {
|
|
t.Fatalf("%s=%v want %v", field, got, want)
|
|
}
|
|
}
|
|
if complete := body["complete"].(bool); !complete {
|
|
t.Fatal("rejected admission was incorrectly marked incomplete")
|
|
}
|
|
talk := metrics["talk_duration_seconds"].(map[string]any)
|
|
if got := talk["total"].(float64); got != 180 {
|
|
t.Fatalf("talk total=%v want 180", got)
|
|
}
|
|
if got := metrics["acd_seconds"].(float64); got != 90 {
|
|
t.Fatalf("acd=%v want 90", got)
|
|
}
|
|
realtime := body["realtime"].(map[string]any)
|
|
if _, ok := realtime["current_answered"]; !ok {
|
|
t.Fatal("realtime current_answered missing")
|
|
}
|
|
if _, ok := realtime["current_cps"]; !ok {
|
|
t.Fatal("realtime current_cps missing")
|
|
}
|
|
if body["definition_version"] != "sip-statistics.v1" || body["timezone"] != "UTC" {
|
|
t.Fatalf("statistics metadata missing: %#v", body)
|
|
}
|
|
resp = request(t, server, http.MethodGet, "/admin/v1/statistics/outbound/timeseries?granularity=minute", "dev-admin-token", "", "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("timeseries: %d", resp.StatusCode)
|
|
}
|
|
series := responseJSON(t, resp)
|
|
if series["definition_version"] != "sip-statistics.v1" {
|
|
t.Fatalf("timeseries definition version = %#v", series["definition_version"])
|
|
}
|
|
}
|
|
|
|
func TestObservationBootAndSequence(t *testing.T) {
|
|
_, store := testServer(t, true)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
if err := store.AdvanceCellBoot(ctx, "cell-a", "boot-next"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obs := ObservationInput{ObservationID: "obs-new", CellID: "cell-a", BootID: "boot-next", Sequence: 1, ObservedAt: now, ReceivedAt: now, Source: "mock", States: map[string]any{"cell_agent": "healthy"}}
|
|
if err := store.InsertObservation(ctx, obs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obs.ObservationID = "obs-zero"
|
|
obs.Sequence = 0
|
|
if err := store.InsertObservation(ctx, obs); err == nil {
|
|
t.Fatal("zero sequence accepted")
|
|
} else if appErr, ok := err.(*AppError); !ok || appErr.Code != "INVALID_OBSERVATION" {
|
|
t.Fatalf("wrong invalid observation error: %v", err)
|
|
}
|
|
obs.Sequence = 1
|
|
obs.ObservationID = "obs-old"
|
|
if err := store.InsertObservation(ctx, obs); err == nil {
|
|
t.Fatal("stale sequence accepted")
|
|
} else if appErr, ok := err.(*AppError); !ok || appErr.Code != "STALE_OBSERVATION" {
|
|
t.Fatalf("wrong stale error: %v", err)
|
|
}
|
|
obs.BootID = "old-boot"
|
|
obs.Sequence = 2
|
|
if err := store.InsertObservation(ctx, obs); err == nil {
|
|
t.Fatal("old boot accepted")
|
|
}
|
|
}
|
|
|
|
func TestStatusRejectsFutureObservationClock(t *testing.T) {
|
|
server, store := testServer(t, true)
|
|
ctx := context.Background()
|
|
if err := store.AdvanceCellBoot(ctx, "cell-a", "future-boot"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
future := time.Now().UTC().Add(2 * time.Minute)
|
|
if err := store.InsertObservation(ctx, ObservationInput{ObservationID: "future-observation", CellID: "cell-a", BootID: "future-boot", Sequence: 1, ObservedAt: future, ReceivedAt: time.Now().UTC(), Source: "mock", States: map[string]any{"cell_agent": "healthy", "asterisk": "healthy", "ari": "healthy", "registration": "registered", "media": "healthy"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp := request(t, server, http.MethodGet, "/admin/v1/cells/cell-a/sip-status", "dev-admin-token", "future-status", "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("future status=%d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
body := responseJSON(t, resp)
|
|
if body["availability"] != "unknown" || body["complete"] != false || body["clock_skew"] != true {
|
|
t.Fatalf("future observation was treated as healthy: %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestReadonlyKeepsPublishedRevisionWhileDraftExists(t *testing.T) {
|
|
server, store := testServer(t, true)
|
|
cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.DisplayName = "draft-only"
|
|
if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "draft-only"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp := request(t, server, http.MethodGet, "/readonly/v1/sip/trunks", "dev-saas-token", "", "", nil)
|
|
body := responseJSON(t, resp)
|
|
trunks := body["trunks"].([]any)
|
|
if len(trunks) != 1 {
|
|
t.Fatalf("published trunks=%d", len(trunks))
|
|
}
|
|
if trunks[0].(map[string]any)["revision"].(float64) != 1 {
|
|
t.Fatal("readonly view followed unpublished latest revision")
|
|
}
|
|
}
|
|
|
|
func TestPartialPublicationRetainsBarrierAndRetriesFailedCell(t *testing.T) {
|
|
server, store := testServer(t, true)
|
|
cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.DisplayName = "partial"
|
|
if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "partial-draft"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server.cfg.MockCellFailures = map[string]string{"cell-b": "CELL_DOWN"}
|
|
resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "partial-1", "2", nil)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("partial publish=%d", resp.StatusCode)
|
|
}
|
|
_ = responseJSON(t, resp)
|
|
trunk, _ := store.GetTrunk(context.Background(), "demo-trunk", ModeMock)
|
|
if trunk.ActiveRevision != 1 {
|
|
t.Fatal("partial publication activated revision")
|
|
}
|
|
if allowed, reason, err := store.AdmissionAllowed(context.Background(), "demo-trunk"); err != nil || allowed || reason != "admission_barrier" {
|
|
t.Fatalf("admission after partial: %v %s", err, reason)
|
|
}
|
|
server.cfg.MockCellFailures = map[string]string{}
|
|
resp = request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "partial-2", "2", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("retry partial=%d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
trunk, _ = store.GetTrunk(context.Background(), "demo-trunk", ModeMock)
|
|
if trunk.ActiveRevision != 2 {
|
|
t.Fatal("retry did not activate revision")
|
|
}
|
|
}
|
|
|
|
func TestRollbackMissingRevisionDoesNotLeaveBarrier(t *testing.T) {
|
|
server, store := testServer(t, true)
|
|
resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/rollback", "dev-publisher-token", "rollback-missing", "1", map[string]any{"target_revision": 99})
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("missing rollback status=%d %s", resp.StatusCode, readBody(resp))
|
|
}
|
|
if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "VERSION_NOT_FOUND" {
|
|
t.Fatalf("missing rollback code=%v", code)
|
|
}
|
|
allowed, reason, err := store.AdmissionAllowed(context.Background(), "demo-trunk")
|
|
if err != nil || !allowed || reason != "ok" {
|
|
t.Fatalf("missing rollback left barrier: allowed=%v reason=%s err=%v", allowed, reason, err)
|
|
}
|
|
}
|
|
|
|
func TestRealPublicationNeedsVerification(t *testing.T) {
|
|
server, store := testServer(t, true)
|
|
server.cfg.Mode = ModeReal
|
|
cfg, err := store.getTrunkConfig(context.Background(), "demo-trunk", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.DisplayName = "real-draft"
|
|
if _, _, err := store.PutTrunk(context.Background(), "demo-trunk", cfg, 1, "fixture:test", "real-draft"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp := request(t, server, http.MethodPost, "/admin/v1/trunks/demo-trunk/publish", "dev-publisher-token", "real-publish", "2", nil)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("real verification status=%d", resp.StatusCode)
|
|
}
|
|
if code := responseJSON(t, resp)["error"].(map[string]any)["code"]; code != "REAL_VERIFICATION_INCOMPLETE" {
|
|
t.Fatalf("real verification code=%v", code)
|
|
}
|
|
}
|
|
|
|
func TestScopedResourcesAndCORS(t *testing.T) {
|
|
server, _ := testServer(t, true)
|
|
cfg := server.cfg
|
|
cfg.Tokens = map[string]TokenSpec{"scoped": {Principal: "operator:scoped", Realm: "admin", Scopes: []string{"sip.provider.read", "sip.trunk.read", "sip.status.read", "sip.statistics.read", "sip.calls.read"}, Resources: map[string][]string{"provider": {"other-provider"}, "trunk": {"other-trunk"}, "cell": {"other-cell"}}}}
|
|
server.cfg = cfg
|
|
resp := request(t, server, http.MethodGet, "/admin/v1/providers", "scoped", "", "", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("scoped list=%d", resp.StatusCode)
|
|
}
|
|
if len(responseJSON(t, resp)["providers"].([]any)) != 0 {
|
|
t.Fatal("scoped provider leaked")
|
|
}
|
|
req := httptest.NewRequest(http.MethodOptions, "/healthz/live", nil)
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
resp, err := server.App().Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("evil origin=%d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func strconvI(v int) string { return fmt.Sprintf("%d", v) }
|
|
func readBody(resp *http.Response) string {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return string(b)
|
|
}
|
|
|
|
func TestAttemptEventsAreIdempotentAndVersioned(t *testing.T) {
|
|
_, store := testServer(t, true)
|
|
ctx := context.Background()
|
|
started := time.Now().UTC().Add(-2 * time.Minute)
|
|
if err := store.UpsertAttempt(ctx, Attempt{AttemptID: "event-attempt", Mode: ModeMock, OriginStatus: "confirmed", AttemptStartedAt: &started, Source: "mock", FactVersion: 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
answered := started.Add(30 * time.Second)
|
|
if err := store.RecordAttemptEvent(ctx, "event-answered", "event-attempt", "answered", answered, time.Now().UTC(), "cell-agent", 2, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.RecordAttemptEvent(ctx, "event-answered", "event-attempt", "answered", started.Add(5*time.Second), time.Now().UTC(), "cell-agent", 1, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ended := answered.Add(time.Minute)
|
|
if err := store.RecordAttemptEvent(ctx, "event-ended", "event-attempt", "ended", ended, time.Now().UTC(), "cell-agent", 2, map[string]any{"termination_reason": "completed"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
attempts, err := store.ListAttempts(ctx, AttemptFilter{Limit: 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found *Attempt
|
|
for i := range attempts {
|
|
if attempts[i].AttemptID == "event-attempt" {
|
|
found = &attempts[i]
|
|
}
|
|
}
|
|
if found == nil || found.AnsweredAt == nil || !found.AnsweredAt.Equal(answered) || found.EndedAt == nil || !found.EndedAt.Equal(ended) || found.FactVersion != 2 {
|
|
t.Fatalf("stale event regressed fact: %#v", found)
|
|
}
|
|
if err := store.UpsertAttempt(ctx, Attempt{AttemptID: "event-attempt", Mode: ModeMock, OriginStatus: "confirmed", AttemptStartedAt: &started, AnsweredAt: &started, Source: "late-source", FactVersion: 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
attempts, err = store.ListAttempts(ctx, AttemptFilter{Limit: 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := range attempts {
|
|
if attempts[i].AttemptID == "event-attempt" && (attempts[i].AnsweredAt == nil || !attempts[i].AnsweredAt.Equal(answered) || attempts[i].FactVersion != 2) {
|
|
t.Fatalf("stale upsert regressed fact: %#v", attempts[i])
|
|
}
|
|
}
|
|
var eventCount int
|
|
if err := store.db.QueryRow("SELECT count(*) FROM attempt_events WHERE event_id IN ('event-answered','event-ended')").Scan(&eventCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eventCount != 2 {
|
|
t.Fatalf("event dedup count = %d", eventCount)
|
|
}
|
|
}
|
|
|
|
func TestNewStoreUpgradesLegacyEgressPoolSchema(t *testing.T) {
|
|
path := t.TempDir() + "/legacy.db"
|
|
legacy, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = legacy.Exec(`CREATE TABLE egress_pools (
|
|
egress_pool_id TEXT PRIMARY KEY,
|
|
display_name TEXT NOT NULL,
|
|
fixed_ips_json TEXT NOT NULL DEFAULT '[]',
|
|
whitelist_status TEXT NOT NULL DEFAULT 'unknown',
|
|
whitelist_checked_at TEXT,
|
|
source TEXT NOT NULL DEFAULT 'mock',
|
|
updated_at TEXT NOT NULL
|
|
)`)
|
|
if err != nil {
|
|
legacy.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := legacy.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
var providerID string
|
|
if err := store.db.QueryRow("SELECT provider_id FROM egress_pools").Scan(&providerID); err != sql.ErrNoRows {
|
|
t.Fatalf("expected upgraded empty table, got %v", err)
|
|
}
|
|
var columnCount int
|
|
if err := store.db.QueryRow("SELECT count(*) FROM pragma_table_info('egress_pools') WHERE name='provider_id'").Scan(&columnCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if columnCount != 1 {
|
|
t.Fatalf("provider_id column count = %d", columnCount)
|
|
}
|
|
}
|
|
|
|
var _ = sql.ErrNoRows
|