Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions db/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"context"
"errors"

sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)

var _ api.AccountStore = (*Store)(nil)

func (s *Store) CreateAccount(ctx context.Context, name string) (api.Account, error) {
name, err := api.NormalizeAccountName(name)
if err != nil {
return api.Account{}, err
}
row, err := s.q.CreateAccount(ctx, name)
if errors.Is(err, pgx.ErrNoRows) {
return api.Account{}, api.ErrAccountNameConflict
}
if err != nil {
return api.Account{}, err
}
return accountFromRow(row), nil
}

func (s *Store) ListAccounts(ctx context.Context) ([]api.Account, error) {
// ponytail: unpaginated operator list; add cursors if account counts grow large.
rows, err := s.q.ListAccounts(ctx)
if err != nil {
return nil, err
}
items := make([]api.Account, 0, len(rows))
for _, row := range rows {
items = append(items, accountFromRow(row))
}
return items, nil
}

func (s *Store) GetAccount(ctx context.Context, id uuid.UUID) (api.Account, error) {
row, err := s.q.GetAccount(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return api.Account{}, api.ErrAccountNotFound
}
if err != nil {
return api.Account{}, err
}
return accountFromRow(row), nil
}

func (s *Store) DeactivateAccount(ctx context.Context, id uuid.UUID) error {
result, err := s.q.DeactivateAccount(ctx, id)
if err != nil {
return err
}
if !result.Found {
return api.ErrAccountNotFound
}
if !result.Deactivated {
return api.ErrAccountInactive
}
return nil
}

func accountFromRow(row sqlc.Account) api.Account {
account := api.Account{ID: row.ID, Name: row.Name, CreatedAt: row.CreatedAt.Time.UTC(), Active: !row.DeactivatedAt.Valid}
if row.DeactivatedAt.Valid {
when := row.DeactivatedAt.Time.UTC()
account.DeactivatedAt = &when
}
return account
}
155 changes: 155 additions & 0 deletions db/accounts_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"context"
"errors"
"os"
"strings"
"testing"
"time"

"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)

func TestAccountsPostgres(t *testing.T) {
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set BEACON_TEST_POSTGRES_DSN for account regression")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
setup, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer setup.Close(context.Background())
schema := "account_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
quoted := pgx.Identifier{schema}.Sanitize()
if _, err := setup.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
t.Fatal(err)
}
defer func() {
if _, err := setup.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
cfg.MaxConns = 4
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
sql, err := migrationFiles.ReadFile("migrations/034_accounts.sql")
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := applyMigration(ctx, pool, string(sql)); err != nil {
t.Fatal(err)
}
}
store := New(pool, 0, 0)
items, err := store.ListAccounts(ctx)
if err != nil || items == nil || len(items) != 0 {
t.Fatal("empty list", err)
}
if _, err := store.CreateAccount(ctx, "\t"); !errors.Is(err, api.ErrAccountNameInvalid) {
t.Fatal("invalid name accepted", err)
}
first, err := store.CreateAccount(ctx, " Montréal 🦀 ")
if err != nil || first.Name != "Montréal 🦀" || !first.Active || first.ID == uuid.Nil || first.CreatedAt.IsZero() {
t.Fatal("create", err)
}
if _, err := store.CreateAccount(ctx, first.Name); !errors.Is(err, api.ErrAccountNameConflict) {
t.Fatal("duplicate", err)
}
if err := store.DeactivateAccount(ctx, first.ID); err != nil {
t.Fatal(err)
}
inactive, err := store.GetAccount(ctx, first.ID)
if err != nil || inactive.Active || inactive.DeactivatedAt == nil {
t.Fatal("deactivate", err)
}
if err := store.DeactivateAccount(ctx, first.ID); !errors.Is(err, api.ErrAccountInactive) {
t.Fatal("repeated deactivate", err)
}
again, _ := store.GetAccount(ctx, first.ID)
if !again.DeactivatedAt.Equal(*inactive.DeactivatedAt) {
t.Fatal("deactivation timestamp changed")
}
reused, err := store.CreateAccount(ctx, first.Name)
if err != nil || reused.ID == first.ID {
t.Fatal("name reuse", err)
}
if _, err := store.CreateAccount(ctx, "montréal 🦀"); err != nil {
t.Fatal("names must be case-sensitive", err)
}
missing := uuid.New()
if _, err := store.GetAccount(ctx, missing); !errors.Is(err, api.ErrAccountNotFound) {
t.Fatal("missing get", err)
}
if err := store.DeactivateAccount(ctx, missing); !errors.Is(err, api.ErrAccountNotFound) {
t.Fatal("missing deactivate", err)
}
items, err = store.ListAccounts(ctx)
if err != nil || len(items) != 3 {
t.Fatal("list lifecycle", err)
}
for i := 1; i < len(items); i++ {
if items[i-1].CreatedAt.Before(items[i].CreatedAt) || (items[i-1].CreatedAt.Equal(items[i].CreatedAt) && items[i-1].ID.String() < items[i].ID.String()) {
t.Fatal("unstable list order")
}
}
start := make(chan struct{})
results := make(chan error, 8)
for i := 0; i < 8; i++ {
go func() { <-start; _, err := store.CreateAccount(ctx, "concurrent"); results <- err }()
}
close(start)
created := 0
for i := 0; i < 8; i++ {
err := <-results
if err == nil {
created++
} else if !errors.Is(err, api.ErrAccountNameConflict) {
t.Fatal("concurrent create", err)
}
}
if created != 1 {
t.Fatalf("created %d accounts with the same name", created)
}
for round := 0; round < 8; round++ {
account, err := store.CreateAccount(ctx, "race-deactivate")
if err != nil {
t.Fatal(err)
}
gate := make(chan struct{})
for i := 0; i < 2; i++ {
go func() { <-gate; results <- store.DeactivateAccount(ctx, account.ID) }()
}
close(gate)
changed := 0
for i := 0; i < 2; i++ {
err := <-results
if err == nil {
changed++
} else if !errors.Is(err, api.ErrAccountInactive) {
t.Fatal("concurrent deactivate", err)
}
}
if changed != 1 {
t.Fatalf("deactivated %d times", changed)
}
}
}
27 changes: 27 additions & 0 deletions db/accounts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"testing"
"time"

sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)

func TestAccountFromRow(t *testing.T) {
when := time.Date(2026, 1, 1, 12, 0, 0, 0, time.FixedZone("test", 3600))
row := sqlc.Account{ID: uuid.New(), Name: "test", CreatedAt: pgtype.Timestamptz{Time: when, Valid: true}}
active := accountFromRow(row)
if active.ID != row.ID || active.Name != row.Name || !active.Active || active.DeactivatedAt != nil || !active.CreatedAt.Equal(when) || active.CreatedAt.Location() != time.UTC {
t.Fatal("active account mapping")
}
row.DeactivatedAt = pgtype.Timestamptz{Time: when.Add(time.Hour), Valid: true}
inactive := accountFromRow(row)
if inactive.Active || inactive.DeactivatedAt == nil || !inactive.DeactivatedAt.Equal(row.DeactivatedAt.Time) {
t.Fatal("inactive account mapping")
}
}
12 changes: 12 additions & 0 deletions db/migrations/034_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later

CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 128 AND name = btrim(name)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX IF NOT EXISTS accounts_name_uidx
ON accounts (name) WHERE deactivated_at IS NULL;
25 changes: 25 additions & 0 deletions db/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1453,3 +1453,28 @@ OR (
AND iata = nn.iata
)
) > 1;

-- name: CreateAccount :one
INSERT INTO accounts (name) VALUES (sqlc.arg(name))
ON CONFLICT (name) WHERE deactivated_at IS NULL DO NOTHING
RETURNING id, name, created_at, deactivated_at;

-- name: ListAccounts :many
SELECT id, name, created_at, deactivated_at FROM accounts
ORDER BY created_at DESC, id DESC;

-- name: GetAccount :one
SELECT id, name, created_at, deactivated_at FROM accounts WHERE id = $1;

-- name: DeactivateAccount :one
-- Lock the current row before deciding the outcome, including when another
-- deactivation commits while this statement is waiting for its row lock.
WITH target AS MATERIALIZED (
SELECT a.id, a.deactivated_at FROM accounts a WHERE a.id = $1 FOR UPDATE
), changed AS (
UPDATE accounts a SET deactivated_at = NOW()
FROM target t WHERE a.id = t.id AND t.deactivated_at IS NULL
RETURNING a.id
)
SELECT EXISTS(SELECT 1 FROM target) AS found,
EXISTS(SELECT 1 FROM changed) AS deactivated;
60 changes: 60 additions & 0 deletions db/sqlc/mock/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions db/sqlc/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading