package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" ) // fakeSchemaPB is a PocketBase stand-in for the "app_settings does not exist" // case: a stack upgraded with PB_BOOTSTRAP off, where the on-boot schema pass // never created the collection the plugin settings live in. type fakeSchemaPB struct { mu sync.Mutex created bool // app_settings exists createCalls int // POST /api/collections unreachable bool // every records call fails with a 502 } func (f *fakeSchemaPB) server(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() switch { case strings.HasSuffix(r.URL.Path, "/auth-with-password"): writeTestJSON(w, http.StatusOK, map[string]any{"token": "test-token"}) // The settings read. 404 until the collection exists — PocketBase // answers a list against a missing collection that way, while an // existing but empty one answers 200 with no items. case r.Method == http.MethodGet && r.URL.Path == "/api/collections/app_settings/records": if f.unreachable { http.Error(w, "connection refused", http.StatusBadGateway) return } if !f.created { http.Error(w, `{"message":"Missing collection context."}`, http.StatusNotFound) return } writeTestJSON(w, http.StatusOK, map[string]any{ "page": 1, "perPage": 1, "totalItems": 0, "totalPages": 1, "items": []any{}, }) // The schema calls EnsureCollection makes. case r.Method == http.MethodGet && r.URL.Path == "/api/collections": items := []any{ map[string]any{"id": "col_users", "name": "users", "fields": []any{ map[string]any{"name": "email", "type": "text"}, }}, } if f.created { items = append(items, map[string]any{"id": "col_app", "name": "app_settings"}) } writeTestJSON(w, http.StatusOK, map[string]any{"items": items}) case r.Method == http.MethodPost && r.URL.Path == "/api/collections": var body struct { Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&body) if body.Name != colAppSettings { t.Errorf("created collection %q, want %q", body.Name, colAppSettings) } f.createCalls++ f.created = true writeTestJSON(w, http.StatusOK, map[string]any{"id": "col_app"}) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) http.Error(w, "unexpected", http.StatusInternalServerError) } })) t.Cleanup(srv.Close) return srv } func writeTestJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func newSchemaTestServer(t *testing.T, f *fakeSchemaPB) *Server { t.Helper() pbSrv := f.server(t) return New(config.Config{UsersCollection: "users"}, pb.New(pbSrv.URL, "admin@test.local", "pw")) } // The guard: a missing settings collection is created rather than retried // forever, so a stack upgraded with the bootstrap off still comes up. func TestLoadPluginsCreatesMissingSettingsCollection(t *testing.T) { f := &fakeSchemaPB{} s := newSchemaTestServer(t, f) if err := s.loadPlugins(context.Background()); err != nil { t.Fatalf("loadPlugins should recover by creating the collection, got %v", err) } if !s.plugins.Ready() { t.Fatal("plugins should be loaded after the collection was created") } f.mu.Lock() calls := f.createCalls f.mu.Unlock() if calls != 1 { t.Fatalf("expected the collection to be created once, got %d", calls) } } // Once the collection exists, nothing is created again. func TestLoadPluginsDoesNotRecreateExistingCollection(t *testing.T) { f := &fakeSchemaPB{created: true} s := newSchemaTestServer(t, f) if err := s.loadPlugins(context.Background()); err != nil { t.Fatalf("loadPlugins: %v", err) } f.mu.Lock() calls := f.createCalls f.mu.Unlock() if calls != 0 { t.Fatalf("an existing collection must not be recreated, got %d creates", calls) } } // A database that is merely unreachable must NOT trigger schema surgery: the // remedy there is to wait, and creating collections against a flaky database is // exactly the wrong reflex. func TestLoadPluginsDoesNotCreateOnOutage(t *testing.T) { f := &fakeSchemaPB{created: true, unreachable: true} s := newSchemaTestServer(t, f) if err := s.loadPlugins(context.Background()); err == nil { t.Fatal("expected an error while the database is unreachable") } if s.plugins.Ready() { t.Fatal("plugins must not report ready after an outage") } f.mu.Lock() calls := f.createCalls f.mu.Unlock() if calls != 0 { t.Fatalf("an outage must not create collections, got %d creates", calls) } }