Assignment 4: add unit, integration, and e2e tests; refactor for testability
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("store: not found")
|
||||
ErrDuplicate = errors.New("store: duplicate")
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// testConn is the shared connection for non-parallel store tests.
|
||||
var testConn *pgx.Conn
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
if !strings.HasSuffix(dbName, "_test") {
|
||||
fmt.Fprintf(os.Stderr, "REFUSING TO RUN: DB_NAME=%q must end with _test\n", dbName)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
conn, err := database.Connect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cannot connect to test DB: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
testConn = conn
|
||||
|
||||
if err := database.CreateTables(testConn); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cannot create tables: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
_ = testConn.Close(context.Background())
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// truncateAll wipes every table. Call at the START of every test.
|
||||
func truncateAll(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_, err := testConn.Exec(ctx, `TRUNCATE TABLE snippets, sessions, users CASCADE`)
|
||||
if err != nil {
|
||||
t.Fatalf("truncate failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newFreshConn opens a brand-new connection to the same DB (for restart tests).
|
||||
func newFreshConn(t *testing.T) *pgx.Conn {
|
||||
t.Helper()
|
||||
conn, err := database.Connect()
|
||||
if err != nil {
|
||||
t.Fatalf("cannot open fresh connection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close(context.Background()) })
|
||||
return conn
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
@@ -45,11 +47,13 @@ func (s *SessionStore) Get(ctx context.Context, token string) (*models.Session,
|
||||
query := `SELECT token, user_id, created_at FROM sessions WHERE token = $1`
|
||||
err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
func seedUser(t *testing.T, id, username string) {
|
||||
t.Helper()
|
||||
us := NewUserStore(testConn)
|
||||
if err := us.Create(context.Background(), &models.User{ID: id, Username: username, PasswordHash: "h"}); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create then lookup returns correct user id", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSessionStore(testConn)
|
||||
if err := s.Create(ctx, &models.Session{Token: "tok1", UserID: "u1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Get(ctx, "tok1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.UserID != "u1" {
|
||||
t.Fatalf("want u1, got %s", got.UserID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown token returns ErrNotFound", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewSessionStore(testConn)
|
||||
_, err := s.Get(ctx, "ghost")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete then lookup returns ErrNotFound", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSessionStore(testConn)
|
||||
if err := s.Create(ctx, &models.Session{Token: "tok1", UserID: "u1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(ctx, "tok1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := s.Get(ctx, "tok1")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deleting already-deleted token does not crash", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
seedUser(t, "u2", "bob")
|
||||
s := NewSessionStore(testConn)
|
||||
_ = s.Create(ctx, &models.Session{Token: "tok1", UserID: "u1"})
|
||||
_ = s.Create(ctx, &models.Session{Token: "tok2", UserID: "u2"})
|
||||
|
||||
_ = s.Delete(ctx, "tok1")
|
||||
// second delete: must not panic
|
||||
_ = s.Delete(ctx, "tok1")
|
||||
|
||||
// other tokens unaffected
|
||||
got, err := s.Get(ctx, "tok2")
|
||||
if err != nil || got.UserID != "u2" {
|
||||
t.Fatalf("tok2 should still be valid: err=%v got=%+v", err, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two sessions for one user are independent", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSessionStore(testConn)
|
||||
_ = s.Create(ctx, &models.Session{Token: "tokA", UserID: "u1"})
|
||||
_ = s.Create(ctx, &models.Session{Token: "tokB", UserID: "u1"})
|
||||
|
||||
if err := s.Delete(ctx, "tokA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Get(ctx, "tokB")
|
||||
if err != nil || got.UserID != "u1" {
|
||||
t.Fatalf("tokB should still be valid after deleting tokA: err=%v got=%+v", err, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionSurvivesNewStore(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
|
||||
// create session on first store
|
||||
s1 := NewSessionStore(testConn)
|
||||
if err := s1.Create(context.Background(), &models.Session{Token: "tok1", UserID: "u1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// brand-new connection to same DB
|
||||
freshConn := newFreshConn(t)
|
||||
s2 := NewSessionStore(freshConn)
|
||||
|
||||
got, err := s2.Get(context.Background(), "tok1")
|
||||
if err != nil {
|
||||
t.Fatalf("session not visible on fresh connection: %v", err)
|
||||
}
|
||||
if got.UserID != "u1" {
|
||||
t.Fatalf("want u1, got %s", got.UserID)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -45,23 +47,28 @@ func (s *SnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet,
|
||||
query := `SELECT id, title, content, user_id FROM snippets WHERE id = $1`
|
||||
err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sn, nil
|
||||
}
|
||||
|
||||
func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) {
|
||||
// ListByOwner returns only the snippets belonging to ownerID.
|
||||
// Return value is always non-nil (empty slice when none).
|
||||
func (s *SnippetStore) ListByOwner(ctx context.Context, ownerID string) ([]*models.Snippet, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
query := `SELECT id, title, content, user_id FROM snippets`
|
||||
rows, err := s.conn.Query(ctx, query)
|
||||
query := `SELECT id, title, content, user_id FROM snippets WHERE user_id = $1`
|
||||
rows, err := s.conn.Query(ctx, query, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
snippets := make([]*models.Snippet, 0)
|
||||
snippets := make([]*models.Snippet, 0) // non-nil!
|
||||
for rows.Next() {
|
||||
var sn models.Snippet
|
||||
if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil {
|
||||
@@ -80,6 +87,12 @@ func (s *SnippetStore) Delete(ctx context.Context, id string) error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
query := `DELETE FROM snippets WHERE id = $1`
|
||||
_, err := s.conn.Exec(ctx, query, id)
|
||||
return err
|
||||
tag, err := s.conn.Exec(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
func TestSnippetStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create then get by id", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSnippetStore(testConn)
|
||||
if err := s.Create(ctx, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetByID(ctx, "s1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "t" || got.Content != "c" || got.UserID != "u1" {
|
||||
t.Fatalf("unexpected snippet: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get unknown id returns ErrNotFound", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewSnippetStore(testConn)
|
||||
_, err := s.GetByID(ctx, "ghost")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list by owner returns only that owner's rows", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
seedUser(t, "u2", "bob")
|
||||
s := NewSnippetStore(testConn)
|
||||
_ = s.Create(ctx, &models.Snippet{ID: "s1", Title: "a", Content: "1", UserID: "u1"})
|
||||
_ = s.Create(ctx, &models.Snippet{ID: "s2", Title: "b", Content: "2", UserID: "u2"})
|
||||
_ = s.Create(ctx, &models.Snippet{ID: "s3", Title: "c", Content: "3", UserID: "u1"})
|
||||
|
||||
got, err := s.ListByOwner(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 snippets for u1, got %d", len(got))
|
||||
}
|
||||
for _, sn := range got {
|
||||
if sn.UserID != "u1" {
|
||||
t.Fatalf("got snippet from wrong owner: %+v", sn)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list for owner with no snippets returns non-nil empty slice", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSnippetStore(testConn)
|
||||
got, err := s.ListByOwner(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("want non-nil slice, got nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("want empty, got %d", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete removes the row", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSnippetStore(testConn)
|
||||
_ = s.Create(ctx, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"})
|
||||
if err := s.Delete(ctx, "s1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := s.GetByID(ctx, "s1")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete unknown id returns ErrNotFound", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewSnippetStore(testConn)
|
||||
err := s.Delete(ctx, "ghost")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ownership retrievable via UserID field on GetByID", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
seedUser(t, "u2", "bob")
|
||||
s := NewSnippetStore(testConn)
|
||||
_ = s.Create(ctx, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"})
|
||||
|
||||
got, err := s.GetByID(ctx, "s1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.UserID != "u1" {
|
||||
t.Fatalf("want owner u1, got %s", got.UserID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSnippetStoreSQLInjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
injections := []string{
|
||||
`' OR '1'='1`,
|
||||
`'; DROP TABLE snippets; --`,
|
||||
`it's a "quote" and 'single'`,
|
||||
}
|
||||
|
||||
for i, payload := range injections {
|
||||
t.Run(payload, func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
seedUser(t, "u1", "alice")
|
||||
s := NewSnippetStore(testConn)
|
||||
|
||||
id := "inj" + string(rune('a'+i))
|
||||
if err := s.Create(ctx, &models.Snippet{ID: id, Title: payload, Content: payload, UserID: "u1"}); err != nil {
|
||||
t.Fatalf("Create with injection payload failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Title != payload {
|
||||
t.Fatalf("title round-trip failed:\nwant %q\ngot %q", payload, got.Title)
|
||||
}
|
||||
if got.Content != payload {
|
||||
t.Fatalf("content round-trip failed:\nwant %q\ngot %q", payload, got.Content)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := testConn.QueryRow(ctx, `SELECT COUNT(*) FROM snippets`).Scan(&count); err != nil {
|
||||
t.Fatalf("snippets table missing or query failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("want 1 row, got %d (table may have been dropped/modified)", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"5803025015/w04/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type UserStore struct {
|
||||
@@ -34,7 +37,14 @@ func (s *UserStore) Create(ctx context.Context, user *models.User) error {
|
||||
|
||||
query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)`
|
||||
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
|
||||
return err
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
@@ -45,6 +55,9 @@ func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models
|
||||
query := `SELECT id, username, password_hash FROM users WHERE username = $1`
|
||||
err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
@@ -58,6 +71,9 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error
|
||||
query := `SELECT id, username, password_hash FROM users WHERE id = $1`
|
||||
err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"5803025015/w04/internal/auth"
|
||||
"5803025015/w04/internal/models"
|
||||
)
|
||||
|
||||
func TestUserStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create then fetch by username", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewUserStore(testConn)
|
||||
if err := s.Create(ctx, &models.User{ID: "u1", Username: "alice", PasswordHash: "h"}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
got, err := s.GetByUsername(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUsername: %v", err)
|
||||
}
|
||||
if got.ID != "u1" || got.Username != "alice" {
|
||||
t.Fatalf("unexpected user: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetch unknown username returns ErrNotFound", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewUserStore(testConn)
|
||||
_, err := s.GetByUsername(ctx, "ghost")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate username returns ErrDuplicate from DB constraint", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewUserStore(testConn)
|
||||
if err := s.Create(ctx, &models.User{ID: "u1", Username: "alice", PasswordHash: "h"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := s.Create(ctx, &models.User{ID: "u2", Username: "alice", PasswordHash: "h"})
|
||||
if !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("want ErrDuplicate, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("usernames are case-sensitive", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewUserStore(testConn)
|
||||
if err := s.Create(ctx, &models.User{ID: "u1", Username: "Alice", PasswordHash: "h"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Create(ctx, &models.User{ID: "u2", Username: "alice", PasswordHash: "h"}); err != nil {
|
||||
t.Fatalf("second (lowercase) user should succeed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stored hash differs from plaintext", func(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewUserStore(testConn)
|
||||
plain := "secret123"
|
||||
hash, err := auth.HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Create(ctx, &models.User{ID: "u1", Username: "alice", PasswordHash: hash}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetByUsername(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PasswordHash == plain {
|
||||
t.Fatal("stored hash equals plaintext!")
|
||||
}
|
||||
if got.PasswordHash != hash {
|
||||
t.Fatal("stored hash was modified")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserStoreConcurrentRegister(t *testing.T) {
|
||||
truncateAll(t)
|
||||
s := NewUserStore(testConn)
|
||||
|
||||
const N = 20
|
||||
var wg sync.WaitGroup
|
||||
var successes, duplicates int32
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
err := s.Create(context.Background(), &models.User{
|
||||
ID: fmt.Sprintf("u%d", i),
|
||||
Username: "concurrent",
|
||||
PasswordHash: "h",
|
||||
})
|
||||
if err == nil {
|
||||
atomic.AddInt32(&successes, 1)
|
||||
} else if errors.Is(err, ErrDuplicate) {
|
||||
atomic.AddInt32(&duplicates, 1)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if successes != 1 {
|
||||
t.Fatalf("want exactly 1 success, got %d", successes)
|
||||
}
|
||||
if duplicates != N-1 {
|
||||
t.Fatalf("want %d duplicates, got %d", N-1, duplicates)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := testConn.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM users WHERE username = 'concurrent'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("want exactly 1 row, got %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user