Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
383da92ca8 | ||
|
|
5915a4e7b1 |
@@ -129,3 +129,26 @@ Keine Commits in dieser Session.
|
||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||
|
||||
---
|
||||
## 2026-08-27 17:53 – 17:57 (4m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** code
|
||||
|
||||
### Commits
|
||||
- e479330 IAM-01: benutzer-datenmodell-crud
|
||||
|
||||
### Geänderte Dateien
|
||||
- cmd/core/main.go | 11 ++++++++++-
|
||||
- go.mod | 9 +++++++++
|
||||
- go.sum | 28 ++++++++++++++++++++++++++++
|
||||
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
|
||||
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
|
||||
- migrations/0002_superadmins.down.sql | 1 +
|
||||
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
|
||||
- migrations/tenant/0001_users.down.sql | 1 +
|
||||
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -34,6 +34,8 @@ func main() {
|
||||
registry := tenant.NewRegistry(registryPool)
|
||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||
tenantHandler := tenant.NewHandler(provisioner)
|
||||
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
|
||||
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
|
||||
|
||||
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
||||
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
||||
@@ -48,6 +50,7 @@ func main() {
|
||||
})
|
||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
|
||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
||||
|
||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||
|
||||
@@ -19,6 +19,9 @@ type Config struct {
|
||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
||||
TenantDSNTemplate string
|
||||
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
||||
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
|
||||
TenantSchemaPath string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -37,6 +40,11 @@ func Load() (Config, error) {
|
||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
||||
}
|
||||
|
||||
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
|
||||
if schemaPath == "" {
|
||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
|
||||
}
|
||||
|
||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
@@ -47,5 +55,6 @@ func Load() (Config, error) {
|
||||
RegistryDSN: dsn,
|
||||
AdminDSN: adminDSN,
|
||||
TenantDSNTemplate: dsnTemplate,
|
||||
TenantSchemaPath: schemaPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package tenant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
||||
@@ -43,3 +46,49 @@ func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(t)
|
||||
}
|
||||
|
||||
// OnboardingHandler stellt den Self-Service-Endpunkt aus TEN-02 bereit —
|
||||
// eigener Handler statt Erweiterung von Handler.CreateTenant, damit die
|
||||
// TEN-01-Schnittstelle (reines Provisioning ohne Admin-Konto) unveraendert
|
||||
// bleibt.
|
||||
type OnboardingHandler struct {
|
||||
service *OnboardingService
|
||||
}
|
||||
|
||||
func NewOnboardingHandler(s *OnboardingService) *OnboardingHandler {
|
||||
return &OnboardingHandler{service: s}
|
||||
}
|
||||
|
||||
type onboardRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
AdminName string `json:"admin_name"`
|
||||
}
|
||||
|
||||
func (h *OnboardingHandler) Onboard(w http.ResponseWriter, r *http.Request) {
|
||||
var req onboardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.Onboard(r.Context(), req.Slug, req.Name, req.AdminEmail, req.AdminName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidSlug),
|
||||
errors.Is(err, ErrMissingField),
|
||||
errors.Is(err, user.ErrInvalidEmail):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case errors.Is(err, ErrTenantExists):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "mandant konnte nicht angelegt werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
var ErrMissingField = errors.New("tenant: pflichtfeld fehlt")
|
||||
|
||||
// OnboardingService implementiert TEN-02: Self-Service-Anlage eines neuen
|
||||
// Mandanten inklusive Ersteinrichtung des Administrator-Kontos in einem
|
||||
// Vorgang. Schlaegt die Admin-Anlage fehl, wird der bereits provisionierte
|
||||
// Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation,
|
||||
// da CREATE DATABASE keine echte cross-database Transaktion erlaubt).
|
||||
type OnboardingService struct {
|
||||
provisioner *Provisioner
|
||||
// tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
||||
// die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der
|
||||
// erste Administrator angelegt werden kann.
|
||||
tenantSchemaPath string
|
||||
}
|
||||
|
||||
func NewOnboardingService(provisioner *Provisioner, tenantSchemaPath string) *OnboardingService {
|
||||
return &OnboardingService{provisioner: provisioner, tenantSchemaPath: tenantSchemaPath}
|
||||
}
|
||||
|
||||
type OnboardResult struct {
|
||||
Tenant Tenant
|
||||
Admin user.User
|
||||
}
|
||||
|
||||
func (s *OnboardingService) Onboard(ctx context.Context, slug, tenantName, adminEmail, adminName string) (OnboardResult, error) {
|
||||
if tenantName == "" {
|
||||
return OnboardResult{}, fmt.Errorf("%w: name", ErrMissingField)
|
||||
}
|
||||
if adminName == "" {
|
||||
return OnboardResult{}, fmt.Errorf("%w: admin-name", ErrMissingField)
|
||||
}
|
||||
if err := ValidateSlug(slug); err != nil {
|
||||
return OnboardResult{}, err
|
||||
}
|
||||
if err := user.ValidateEmail(adminEmail); err != nil {
|
||||
return OnboardResult{}, err
|
||||
}
|
||||
|
||||
t, err := s.provisioner.Provision(ctx, slug, tenantName)
|
||||
if err != nil {
|
||||
return OnboardResult{}, err
|
||||
}
|
||||
|
||||
admin, err := s.setupAdmin(ctx, t, adminEmail, adminName)
|
||||
if err != nil {
|
||||
_ = s.provisioner.Deprovision(ctx, t)
|
||||
return OnboardResult{}, err
|
||||
}
|
||||
|
||||
return OnboardResult{Tenant: t, Admin: admin}, nil
|
||||
}
|
||||
|
||||
func (s *OnboardingService) setupAdmin(ctx context.Context, t Tenant, adminEmail, adminName string) (user.User, error) {
|
||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||
if err != nil {
|
||||
return user.User{}, fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
schema, err := os.ReadFile(s.tenantSchemaPath)
|
||||
if err != nil {
|
||||
return user.User{}, fmt.Errorf("tenant-schema lesen: %w", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(schema)); err != nil {
|
||||
return user.User{}, fmt.Errorf("tenant-schema anwenden: %w", err)
|
||||
}
|
||||
|
||||
store := user.NewTenantUserStore(pool)
|
||||
return store.Create(ctx, adminEmail, adminName)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt
|
||||
// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL).
|
||||
const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql"
|
||||
|
||||
func TestOnboarding_ValidationErrors(t *testing.T) {
|
||||
// Reine Eingabevalidierung, braucht keine Datenbank.
|
||||
svc := NewOnboardingService(nil, schemaPathForTest)
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
slug string
|
||||
tenantName string
|
||||
email string
|
||||
adminName string
|
||||
wantErr error
|
||||
}{
|
||||
{"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField},
|
||||
{"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField},
|
||||
{"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug},
|
||||
{"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName)
|
||||
if !errors.Is(err, c.wantErr) {
|
||||
t.Fatalf("erwartet %v, habe %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) {
|
||||
t.Helper()
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("admin pool: %v", err)
|
||||
}
|
||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("registry pool: %v", err)
|
||||
}
|
||||
|
||||
if _, err := registryPool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
db_name TEXT NOT NULL UNIQUE,
|
||||
db_dsn TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
t.Fatalf("registry-schema: %v", err)
|
||||
}
|
||||
|
||||
registry := NewRegistry(registryPool)
|
||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||
svc := NewOnboardingService(provisioner, schemaPathForTest)
|
||||
|
||||
cleanup := func() {
|
||||
registryPool.Close()
|
||||
adminPool.Close()
|
||||
}
|
||||
return svc, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang.
|
||||
func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) {
|
||||
svc, cleanup := newTestOnboardingService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
p, _ := pgxpool.New(ctx, adminDSN)
|
||||
defer p.Close()
|
||||
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`)
|
||||
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`)
|
||||
})
|
||||
|
||||
result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin")
|
||||
if err != nil {
|
||||
t.Fatalf("onboard: %v", err)
|
||||
}
|
||||
if result.Tenant.Slug != "onboardtest" {
|
||||
t.Fatalf("tenant slug = %q", result.Tenant.Slug)
|
||||
}
|
||||
if result.Admin.Email != "admin@onboardtest.example" {
|
||||
t.Fatalf("admin email = %q", result.Admin.Email)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, result.Tenant.DBDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("connect tenant db: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil {
|
||||
t.Fatalf("admin in tenant-db pruefen: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen,
|
||||
// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant.
|
||||
func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) {
|
||||
svc, cleanup := newTestOnboardingService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
p, _ := pgxpool.New(ctx, adminDSN)
|
||||
defer p.Close()
|
||||
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`)
|
||||
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`)
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin")
|
||||
results[i] = err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
successCount++
|
||||
} else if !errors.Is(err, ErrTenantExists) {
|
||||
t.Fatalf("unerwarteter fehler: %v", err)
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,17 @@ package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrTenantExists wird zurueckgegeben, wenn Slug oder Datenbankname eines
|
||||
// Mandanten bereits vergeben sind (TEN-02 Akzeptanzkriterium 3).
|
||||
var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
|
||||
|
||||
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
||||
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
||||
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
||||
@@ -39,6 +45,14 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
||||
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
||||
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
||||
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
// 42P04 (duplicate_database) im Normalfall; bei zwei echt parallelen
|
||||
// CREATE DATABASE auf denselben Namen liefert Postgres stattdessen den
|
||||
// internen Unique-Index-Konflikt 23505 auf pg_database — beide Faelle
|
||||
// bedeuten: der Tenant existiert bereits bzw. wird gerade angelegt.
|
||||
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
|
||||
return Tenant{}, ErrTenantExists
|
||||
}
|
||||
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
||||
}
|
||||
|
||||
@@ -60,6 +74,10 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
p.rollbackDatabase(ctx, dbName)
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation auf slug
|
||||
return Tenant{}, ErrTenantExists
|
||||
}
|
||||
return Tenant{}, err
|
||||
}
|
||||
|
||||
@@ -76,3 +94,16 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
||||
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
||||
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||
}
|
||||
|
||||
// Deprovision entfernt einen bereits erfolgreich registrierten Tenant wieder
|
||||
// vollstaendig (Registry-Eintrag + Datenbank). Wird als Kompensation
|
||||
// verwendet, wenn ein Schritt NACH Provision fehlschlaegt (z.B. die
|
||||
// Admin-Account-Anlage in TEN-02) — Provision selbst bleibt dadurch die
|
||||
// einzige Stelle, die "erfolgreich provisioniert" garantieren muss.
|
||||
func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
|
||||
if err := p.registry.Delete(ctx, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
p.rollbackDatabase(ctx, t.DBName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@ func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Delete entfernt einen Registry-Eintrag. Wird von Provisioner.Deprovision
|
||||
// als Kompensation verwendet, wenn nach erfolgreicher Registrierung ein
|
||||
// nachgelagerter Schritt (z.B. Admin-Account-Anlage in TEN-02) fehlschlaegt.
|
||||
func (r *Registry) Delete(ctx context.Context, id string) error {
|
||||
if _, err := r.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("tenant loeschen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package tenantsettings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Handler stellt Lesen/Schreiben der Tenant-Einstellungen als schlanke
|
||||
// Vorbereitung der Schnittstelle bereit (API-01/TEN-05 sind nicht Teil
|
||||
// dieser Kachel). tenantID wird hier als Pfad-/Query-Parameter erwartet,
|
||||
// eine echte Auth-/Berechtigungspruefung kommt erst mit IAM-02/RBAC-01.
|
||||
type Handler struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewHandler(store *Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.URL.Query().Get("tenant_id")
|
||||
if tenantID == "" {
|
||||
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Get(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
http.Error(w, "einstellungen konnten nicht geladen werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
ColorScheme *string `json:"color_scheme"`
|
||||
Timezone *string `json:"timezone"`
|
||||
Language *string `json:"language"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.URL.Query().Get("tenant_id")
|
||||
if tenantID == "" {
|
||||
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Update(r.Context(), tenantID, Patch{
|
||||
DisplayName: req.DisplayName,
|
||||
LogoURL: req.LogoURL,
|
||||
ColorScheme: req.ColorScheme,
|
||||
Timezone: req.Timezone,
|
||||
Language: req.Language,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "einstellungen konnten nicht gespeichert werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tenantsettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) (*Store, string, string, func()) {
|
||||
t.Helper()
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
pool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
db_name TEXT NOT NULL UNIQUE,
|
||||
db_dsn TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
|
||||
version INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_settings_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL,
|
||||
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
|
||||
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
var tenantA, tenantB string
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil {
|
||||
t.Fatalf("tenant a anlegen: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil {
|
||||
t.Fatalf("tenant b anlegen: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB)
|
||||
pool.Close()
|
||||
}
|
||||
return NewStore(pool), tenantA, tenantB, cleanup
|
||||
}
|
||||
|
||||
func strp(s string) *string { return &s }
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler.
|
||||
func TestGet_UnsetTenantReturnsDefaults(t *testing.T) {
|
||||
store, tenantA, _, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := store.Get(ctx, tenantA)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
want := Defaults()
|
||||
if got != want {
|
||||
t.Fatalf("erwartet defaults %+v, habe %+v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default.
|
||||
func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) {
|
||||
store, tenantA, _, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if got.DisplayName != "Acme GmbH" {
|
||||
t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName)
|
||||
}
|
||||
d := Defaults()
|
||||
if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language {
|
||||
t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got)
|
||||
}
|
||||
if got.Version != 1 {
|
||||
t.Fatalf("erwartet version 1, habe %d", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich
|
||||
// nicht auf einen anderen aus.
|
||||
func TestUpdate_IsolatedBetweenTenants(t *testing.T) {
|
||||
store, tenantA, tenantB, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil {
|
||||
t.Fatalf("update a: %v", err)
|
||||
}
|
||||
|
||||
gotB, err := store.Get(ctx, tenantB)
|
||||
if err != nil {
|
||||
t.Fatalf("get b: %v", err)
|
||||
}
|
||||
if gotB != Defaults() {
|
||||
t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar.
|
||||
func TestUpdate_HistoryTracksVersions(t *testing.T) {
|
||||
store, tenantA, _, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil {
|
||||
t.Fatalf("update 1: %v", err)
|
||||
}
|
||||
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil {
|
||||
t.Fatalf("update 2: %v", err)
|
||||
}
|
||||
if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil {
|
||||
t.Fatalf("update 3: %v", err)
|
||||
}
|
||||
|
||||
history, err := store.History(ctx, tenantA)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(history) != 3 {
|
||||
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
|
||||
}
|
||||
if history[0].DisplayName != "v1" || history[0].Version != 1 {
|
||||
t.Fatalf("history[0] unerwartet: %+v", history[0])
|
||||
}
|
||||
if history[1].DisplayName != "v2" || history[1].Version != 2 {
|
||||
t.Fatalf("history[1] unerwartet: %+v", history[1])
|
||||
}
|
||||
if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 {
|
||||
t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package tenantsettings implementiert Core TEN-03: pro-Tenant-Einstellungen
|
||||
// (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) mit versionierter
|
||||
// Aenderungshistorie und dokumentierten Systemvoreinstellungen.
|
||||
package tenantsettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Settings ist der vollstaendig AUFGELOESTE Stand (jede leere Einstellung ist
|
||||
// bereits durch ihren Default ersetzt) — Aufrufer muessen sich nie selbst um
|
||||
// Systemvoreinstellungen kuemmern (Akzeptanzkriterium 2).
|
||||
type Settings struct {
|
||||
DisplayName string
|
||||
LogoURL string
|
||||
ColorScheme string
|
||||
Timezone string
|
||||
Language string
|
||||
Version int
|
||||
}
|
||||
|
||||
// Defaults liefert die dokumentierten Systemvoreinstellungen.
|
||||
func Defaults() Settings {
|
||||
return Settings{
|
||||
DisplayName: "",
|
||||
LogoURL: "",
|
||||
ColorScheme: "system",
|
||||
Timezone: "UTC",
|
||||
Language: "de",
|
||||
Version: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Patch enthaelt nur die Felder, die tatsaechlich geaendert werden sollen —
|
||||
// nil bedeutet "unveraendert lassen", nicht "auf Default zuruecksetzen".
|
||||
type Patch struct {
|
||||
DisplayName *string
|
||||
LogoURL *string
|
||||
ColorScheme *string
|
||||
Timezone *string
|
||||
Language *string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// Get liefert die aufgeloesten Einstellungen eines Tenants. Existiert noch
|
||||
// kein Datensatz oder sind einzelne Spalten NULL, werden die jeweiligen
|
||||
// Systemvoreinstellungen eingesetzt (Akzeptanzkriterium 2 / Pruefung 2) —
|
||||
// niemals ein Fehler wegen fehlender Werte.
|
||||
func (s *Store) Get(ctx context.Context, tenantID string) (Settings, error) {
|
||||
var displayName, logoURL, colorScheme, timezone, language *string
|
||||
var version int
|
||||
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
||||
FROM tenant_settings WHERE tenant_id = $1
|
||||
`, tenantID)
|
||||
err := row.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Defaults(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("einstellungen lesen: %w", err)
|
||||
}
|
||||
|
||||
d := Defaults()
|
||||
return Settings{
|
||||
DisplayName: orDefault(displayName, d.DisplayName),
|
||||
LogoURL: orDefault(logoURL, d.LogoURL),
|
||||
ColorScheme: orDefault(colorScheme, d.ColorScheme),
|
||||
Timezone: orDefault(timezone, d.Timezone),
|
||||
Language: orDefault(language, d.Language),
|
||||
Version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func orDefault(v *string, def string) string {
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// Update wendet patch auf den aktuellen Stand an, erhoeht die Version und
|
||||
// schreibt Stand + Historieneintrag atomar (Akzeptanzkriterium 3). Nicht in
|
||||
// patch gesetzte Felder bleiben unveraendert (bzw. weiterhin auf Default,
|
||||
// wenn sie es vorher schon waren).
|
||||
func (s *Store) Update(ctx context.Context, tenantID string, patch Patch) (Settings, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage *string
|
||||
var version int
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
||||
FROM tenant_settings WHERE tenant_id = $1 FOR UPDATE
|
||||
`, tenantID)
|
||||
err = row.Scan(&rawDisplayName, &rawLogoURL, &rawColorScheme, &rawTimezone, &rawLanguage, &version)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Settings{}, fmt.Errorf("aktuellen stand lesen: %w", err)
|
||||
}
|
||||
|
||||
if patch.DisplayName != nil {
|
||||
rawDisplayName = patch.DisplayName
|
||||
}
|
||||
if patch.LogoURL != nil {
|
||||
rawLogoURL = patch.LogoURL
|
||||
}
|
||||
if patch.ColorScheme != nil {
|
||||
rawColorScheme = patch.ColorScheme
|
||||
}
|
||||
if patch.Timezone != nil {
|
||||
rawTimezone = patch.Timezone
|
||||
}
|
||||
if patch.Language != nil {
|
||||
rawLanguage = patch.Language
|
||||
}
|
||||
newVersion := version + 1
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO tenant_settings (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
display_name = $2, logo_url = $3, color_scheme = $4, timezone = $5, language = $6, version = $7, updated_at = now()
|
||||
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
|
||||
return Settings{}, fmt.Errorf("einstellungen speichern: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO tenant_settings_history (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, changed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
|
||||
return Settings{}, fmt.Errorf("historie schreiben: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Settings{}, fmt.Errorf("transaktion committen: %w", err)
|
||||
}
|
||||
|
||||
return s.Get(ctx, tenantID)
|
||||
}
|
||||
|
||||
// History liefert die vollstaendige Versionshistorie eines Tenants
|
||||
// (Akzeptanzkriterium 3 / Pruefung 3-Vorbereitung).
|
||||
func (s *Store) History(ctx context.Context, tenantID string) ([]Settings, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT display_name, logo_url, color_scheme, timezone, language, version
|
||||
FROM tenant_settings_history WHERE tenant_id = $1 ORDER BY version
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("historie abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
d := Defaults()
|
||||
var out []Settings
|
||||
for rows.Next() {
|
||||
var displayName, logoURL, colorScheme, timezone, language *string
|
||||
var version int
|
||||
if err := rows.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version); err != nil {
|
||||
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
||||
}
|
||||
out = append(out, Settings{
|
||||
DisplayName: orDefault(displayName, d.DisplayName),
|
||||
LogoURL: orDefault(logoURL, d.LogoURL),
|
||||
ColorScheme: orDefault(colorScheme, d.ColorScheme),
|
||||
Timezone: orDefault(timezone, d.Timezone),
|
||||
Language: orDefault(language, d.Language),
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS tenant_settings_history;
|
||||
DROP TABLE IF EXISTS tenant_settings;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Pro-Tenant-Einstellungen & Branding (TEN-03, siehe core-kanban/tickets/TEN-03.md).
|
||||
-- Alle Spalten NULLABLE: ein fehlender Wert bedeutet "System-Default
|
||||
-- verwenden" (Akzeptanzkriterium 2), niemals ein Fehler.
|
||||
CREATE TABLE tenant_settings (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||
display_name TEXT,
|
||||
logo_url TEXT,
|
||||
color_scheme TEXT,
|
||||
timezone TEXT,
|
||||
language TEXT,
|
||||
version INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE tenant_settings_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
display_name TEXT,
|
||||
logo_url TEXT,
|
||||
color_scheme TEXT,
|
||||
timezone TEXT,
|
||||
language TEXT,
|
||||
version INT NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX tenant_settings_history_tenant_idx ON tenant_settings_history (tenant_id, version);
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
||||
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
||||
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
||||
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
||||
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||
#
|
||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
||||
set -euo pipefail
|
||||
|
||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||
ROLE="nexarch_test"
|
||||
|
||||
export PGPASSWORD="$PASS"
|
||||
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||
|
||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||
for db in $dbs; do
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
||||
done
|
||||
|
||||
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
||||
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
||||
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
||||
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||
#
|
||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
||||
set -euo pipefail
|
||||
|
||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
||||
|
||||
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
||||
|
||||
echo "== go build =="
|
||||
go build ./...
|
||||
|
||||
echo "== go vet =="
|
||||
go vet ./...
|
||||
|
||||
echo "== go test (-p 1) =="
|
||||
go test ./... -p 1 -count=1
|
||||
Reference in New Issue
Block a user