Assignment 4: add unit, integration, and e2e tests; refactor for testability

This commit is contained in:
angelicatesvara07-crypto
2026-10-03 19:09:40 +07:00
parent 4ac21847fc
commit 3945920abb
24 changed files with 2753 additions and 147 deletions
+8
View File
@@ -0,0 +1,8 @@
package store
import "errors"
var (
ErrNotFound = errors.New("store: not found")
ErrDuplicate = errors.New("store: duplicate")
)
+61
View File
@@ -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
}
+5 -1
View File
@@ -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()
+120
View File
@@ -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)
}
}
+19 -6
View File
@@ -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
}
+158
View File
@@ -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)
}
})
}
}
+17 -1
View File
@@ -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
+130
View File
@@ -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)
}
}