Assignment 4: tests, interface refactor, e2e suite

This commit is contained in:
Zagar Nashrullah
2026-10-03 19:35:30 +07:00
parent 6e7e35d9a5
commit 004bf4e292
25 changed files with 2644 additions and 151 deletions
+6
View File
@@ -0,0 +1,6 @@
package store
import "errors"
// ErrNotFound dikembalikan saat data yang dicari (user, session, snippet) tidak ada.
var ErrNotFound = errors.New("not found")
+75
View File
@@ -0,0 +1,75 @@
package store
import (
"context"
"log"
"os"
"strings"
"sync"
"testing"
"5803025048/W04/internal/database"
"5803025048/W04/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// testConn dipakai bersama oleh seluruh tes di package ini.
// Tes berjalan berurutan (tanpa t.Parallel), jadi aman.
var testConn *pgx.Conn
func TestMain(m *testing.M) {
// Safety guard: tes ini men-TRUNCATE semua tabel.
name := os.Getenv("DB_NAME")
if !strings.HasSuffix(name, "_test") {
log.Fatalf("refusing to run store tests: DB_NAME must end in _test (got %q)", name)
}
// Tidak boleh skip: kalau DB tidak ada / tidak terjangkau, tes GAGAL.
conn, err := database.Connect()
if err != nil {
log.Fatalf("store tests need a reachable PostgreSQL: %v", err)
}
// Skema dibuat lewat kode yang sama dengan yang dipakai server saat startup.
if err := database.CreateTables(conn); err != nil {
log.Fatalf("could not create schema: %v", err)
}
testConn = conn
code := m.Run()
_ = conn.Close(context.Background())
os.Exit(code)
}
// resetDB mengosongkan semua tabel. Dipanggil di awal setiap tes.
func resetDB(t *testing.T) {
t.Helper()
if _, err := testConn.Exec(context.Background(), `TRUNCATE TABLE sessions, snippets, users CASCADE`); err != nil {
t.Fatalf("truncate tables: %v", err)
}
}
func newTestStores() (*UserStore, *SessionStore, *SnippetStore) {
mu := &sync.Mutex{}
return NewUserStore(testConn, mu), NewSessionStore(testConn, mu), NewSnippetStore(testConn, mu)
}
func mustCreateUser(t *testing.T, us *UserStore, username string) *models.User {
t.Helper()
u := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-of-" + username}
if err := us.Create(context.Background(), u); err != nil {
t.Fatalf("create user %q: %v", username, err)
}
return u
}
// countRows hanya dipanggil dengan nama tabel konstan dari tes ini.
func countRows(t *testing.T, table string) int {
t.Helper()
var n int
if err := testConn.QueryRow(context.Background(), `SELECT COUNT(*) FROM `+table).Scan(&n); err != nil {
t.Fatalf("count rows in %s: %v", table, err)
}
return n
}
+4
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"errors"
"sync"
"time"
@@ -45,6 +46,9 @@ 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
+122
View File
@@ -0,0 +1,122 @@
package store
import (
"context"
"errors"
"testing"
"5803025048/W04/internal/database"
"5803025048/W04/internal/models"
)
func TestSessionStore(t *testing.T) {
resetDB(t)
ctx := context.Background()
t.Run("create then look up token returns the user id", func(t *testing.T) {
resetDB(t)
us, ss, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
if err := ss.Create(ctx, &models.Session{Token: "tok-1", UserID: u.ID}); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := ss.Get(ctx, "tok-1")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.UserID != u.ID || got.Token != "tok-1" {
t.Fatalf("session = %+v, want user %s", got, u.ID)
}
if got.CreatedAt.IsZero() {
t.Fatal("CreatedAt must be set")
}
})
t.Run("unknown token returns ErrNotFound", func(t *testing.T) {
resetDB(t)
_, ss, _ := newTestStores()
if _, err := ss.Get(ctx, "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("delete then look up returns ErrNotFound", func(t *testing.T) {
resetDB(t)
us, ss, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
if err := ss.Create(ctx, &models.Session{Token: "tok-1", UserID: u.ID}); err != nil {
t.Fatalf("Create: %v", err)
}
if err := ss.Delete(ctx, "tok-1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := ss.Get(ctx, "tok-1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("deleting an already-deleted token does not panic or touch other tokens", func(t *testing.T) {
resetDB(t)
us, ss, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
for _, tok := range []string{"tok-a", "tok-b"} {
if err := ss.Create(ctx, &models.Session{Token: tok, UserID: u.ID}); err != nil {
t.Fatalf("Create %s: %v", tok, err)
}
}
_ = ss.Delete(ctx, "tok-a")
_ = ss.Delete(ctx, "tok-a") // kedua kalinya: hanya tidak boleh panic
if _, err := ss.Get(ctx, "tok-b"); err != nil {
t.Fatalf("tok-b must still be valid, got err = %v", err)
}
})
t.Run("two sessions for one user are independent", func(t *testing.T) {
resetDB(t)
us, ss, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
for _, tok := range []string{"tok-1", "tok-2"} {
if err := ss.Create(ctx, &models.Session{Token: tok, UserID: u.ID}); err != nil {
t.Fatalf("Create %s: %v", tok, err)
}
}
if err := ss.Delete(ctx, "tok-1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := ss.Get(ctx, "tok-1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("tok-1 err = %v, want ErrNotFound", err)
}
got, err := ss.Get(ctx, "tok-2")
if err != nil || got.UserID != u.ID {
t.Fatalf("tok-2 must remain valid, got %+v, err = %v", got, err)
}
})
}
func TestSessionSurvivesNewStore(t *testing.T) {
t.Run("token created by one store is found by a store on a new connection", func(t *testing.T) {
resetDB(t)
us, ss, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
if err := ss.Create(context.Background(), &models.Session{Token: "persist-tok", UserID: u.ID}); err != nil {
t.Fatalf("Create: %v", err)
}
conn2, err := database.Connect()
if err != nil {
t.Fatalf("open second connection: %v", err)
}
defer conn2.Close(context.Background())
ss2 := NewSessionStore(conn2)
got, err := ss2.Get(context.Background(), "persist-tok")
if err != nil {
t.Fatalf("Get via new store: %v", err)
}
if got.UserID != u.ID {
t.Fatalf("user id = %q, want %q", got.UserID, u.ID)
}
})
}
+37 -2
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"errors"
"sync"
"5803025048/W04/internal/models"
@@ -45,11 +46,39 @@ 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) GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) {
s.mu.Lock()
defer s.mu.Unlock()
query := `SELECT id, title, content, user_id FROM snippets WHERE user_id = $1`
rows, err := s.conn.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
snippets := make([]*models.Snippet, 0)
for rows.Next() {
var sn models.Snippet
if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil {
return nil, err
}
snippets = append(snippets, &sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
return snippets, nil
}
func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -80,6 +109,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
}
+224
View File
@@ -0,0 +1,224 @@
package store
import (
"context"
"errors"
"testing"
"5803025048/W04/internal/models"
"github.com/google/uuid"
)
func mustCreateSnippet(t *testing.T, sn *SnippetStore, ownerID, title, content string) *models.Snippet {
t.Helper()
s := &models.Snippet{ID: uuid.New().String(), Title: title, Content: content, UserID: ownerID}
if err := sn.Create(context.Background(), s); err != nil {
t.Fatalf("create snippet: %v", err)
}
return s
}
func TestSnippetStore(t *testing.T) {
resetDB(t)
ctx := context.Background()
t.Run("create then get by id", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
u := mustCreateUser(t, us, "alice")
s := mustCreateSnippet(t, sn, u.ID, "Title", "Content")
got, err := sn.GetByID(ctx, s.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.ID != s.ID || got.Title != "Title" || got.Content != "Content" {
t.Fatalf("got %+v, want %+v", got, s)
}
})
t.Run("get unknown id returns ErrNotFound", func(t *testing.T) {
resetDB(t)
_, _, sn := newTestStores()
if _, err := sn.GetByID(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("list by owner returns only that owner's rows", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
alice := mustCreateUser(t, us, "alice")
bob := mustCreateUser(t, us, "bob")
a1 := mustCreateSnippet(t, sn, alice.ID, "a1", "c")
a2 := mustCreateSnippet(t, sn, alice.ID, "a2", "c")
mustCreateSnippet(t, sn, bob.ID, "b1", "c")
got, err := sn.GetByUserID(ctx, alice.ID)
if err != nil {
t.Fatalf("GetByUserID: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d snippets, want 2", len(got))
}
for _, s := range got {
if s.ID != a1.ID && s.ID != a2.ID {
t.Fatalf("snippet %s does not belong to alice", s.ID)
}
if s.UserID != alice.ID {
t.Fatalf("snippet owner = %s, want %s", s.UserID, alice.ID)
}
}
})
t.Run("list for an owner with no snippets returns a non-nil empty slice", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
u := mustCreateUser(t, us, "alice")
got, err := sn.GetByUserID(ctx, u.ID)
if err != nil {
t.Fatalf("GetByUserID: %v", err)
}
if got == nil {
t.Fatal("slice must be non-nil")
}
if len(got) != 0 {
t.Fatalf("len = %d, want 0", len(got))
}
})
t.Run("delete removes the row", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
u := mustCreateUser(t, us, "alice")
s := mustCreateSnippet(t, sn, u.ID, "t", "c")
if err := sn.Delete(ctx, s.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := sn.GetByID(ctx, s.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("after delete err = %v, want ErrNotFound", err)
}
if n := countRows(t, "snippets"); n != 0 {
t.Fatalf("snippets rows = %d, want 0", n)
}
})
t.Run("delete unknown id returns ErrNotFound", func(t *testing.T) {
resetDB(t)
_, _, sn := newTestStores()
if err := sn.Delete(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("ownership of a snippet can be retrieved", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
owner := mustCreateUser(t, us, "owner")
other := mustCreateUser(t, us, "other")
s := mustCreateSnippet(t, sn, owner.ID, "t", "c")
got, err := sn.GetByID(ctx, s.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.UserID != owner.ID {
t.Fatalf("owner = %q, want %q", got.UserID, owner.ID)
}
if got.UserID == other.ID {
t.Fatal("owner must not be the other user")
}
})
t.Run("GetAll returns snippets of every owner", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
alice := mustCreateUser(t, us, "alice")
bob := mustCreateUser(t, us, "bob")
mustCreateSnippet(t, sn, alice.ID, "a", "c")
mustCreateSnippet(t, sn, bob.ID, "b", "c")
got, err := sn.GetAll(ctx)
if err != nil {
t.Fatalf("GetAll: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d snippets, want 2", len(got))
}
})
t.Run("Create assigns an id when none is given", func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
u := mustCreateUser(t, us, "alice")
s := &models.Snippet{Title: "t", Content: "c", UserID: u.ID}
if err := sn.Create(ctx, s); err != nil {
t.Fatalf("Create: %v", err)
}
if s.ID == "" {
t.Fatal("Create must assign an id")
}
})
}
func TestSnippetStoreSQLInjection(t *testing.T) {
ctx := context.Background()
payloads := []struct {
name string
payload string
}{
{"always-true OR clause", `' OR '1'='1`},
{"DROP TABLE statement", `'; DROP TABLE snippets; --`},
{"title with a single quote", `O'Reilly's "quoted" title`},
}
for _, tc := range payloads {
t.Run(tc.name, func(t *testing.T) {
resetDB(t)
us, _, sn := newTestStores()
u := mustCreateUser(t, us, "alice")
// Payload sebagai title DAN content: harus tersimpan apa adanya.
s := mustCreateSnippet(t, sn, u.ID, tc.payload, tc.payload)
got, err := sn.GetByID(ctx, s.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Title != tc.payload || got.Content != tc.payload {
t.Fatalf("round trip changed data: title=%q content=%q", got.Title, got.Content)
}
list, err := sn.GetByUserID(ctx, u.ID)
if err != nil {
t.Fatalf("GetByUserID: %v", err)
}
if len(list) != 1 || list[0].Title != tc.payload {
t.Fatalf("list = %+v, want exactly the one stored snippet", list)
}
// Payload sebagai id / user id: tidak boleh mengembalikan baris apa pun.
if _, err := sn.GetByID(ctx, tc.payload); !errors.Is(err, ErrNotFound) {
t.Fatalf("GetByID(payload) err = %v, want ErrNotFound", err)
}
other, err := sn.GetByUserID(ctx, tc.payload)
if err != nil {
t.Fatalf("GetByUserID(payload): %v", err)
}
if len(other) != 0 {
t.Fatalf("GetByUserID(payload) returned %d rows, want 0", len(other))
}
if err := sn.Delete(ctx, tc.payload); !errors.Is(err, ErrNotFound) {
t.Fatalf("Delete(payload) err = %v, want ErrNotFound", err)
}
// Tabel masih ada dan hanya berisi baris yang diharapkan.
if n := countRows(t, "snippets"); n != 1 {
t.Fatalf("snippets rows = %d, want 1", n)
}
})
}
}
+19 -1
View File
@@ -2,13 +2,18 @@ package store
import (
"context"
"errors"
"sync"
"5803025048/W04/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ErrUsernameTaken dikembalikan saat Create() gagal karena username sudah dipakai (unique constraint).
var ErrUsernameTaken = errors.New("username already exists")
type UserStore struct {
conn *pgx.Conn
mu *sync.Mutex
@@ -34,7 +39,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 ErrUsernameTaken
}
return err
}
return nil
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
@@ -45,6 +57,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 +73,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
+166
View File
@@ -0,0 +1,166 @@
package store
import (
"context"
"errors"
"sync"
"testing"
"5803025048/W04/internal/auth"
"5803025048/W04/internal/models"
"github.com/google/uuid"
)
func TestUserStore(t *testing.T) {
resetDB(t)
ctx := context.Background()
t.Run("create then fetch by username", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
got, err := us.GetByUsername(ctx, "alice")
if err != nil {
t.Fatalf("GetByUsername: %v", err)
}
if got.ID != u.ID || got.Username != "alice" || got.PasswordHash != u.PasswordHash {
t.Fatalf("fetched user %+v does not match created user %+v", got, u)
}
})
t.Run("fetch unknown username returns ErrNotFound", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
if _, err := us.GetByUsername(ctx, "ghost"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("duplicate username returns ErrUsernameTaken", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
mustCreateUser(t, us, "alice")
err := us.Create(ctx, &models.User{ID: uuid.New().String(), Username: "alice", PasswordHash: "other"})
if !errors.Is(err, ErrUsernameTaken) {
t.Fatalf("err = %v, want ErrUsernameTaken", err)
}
if n := countRows(t, "users"); n != 1 {
t.Fatalf("users rows = %d, want 1", n)
}
})
t.Run("usernames are case-sensitive", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
upper := mustCreateUser(t, us, "Alice")
lower := mustCreateUser(t, us, "alice")
if upper.ID == lower.ID {
t.Fatal("expected two distinct users")
}
gotUpper, err := us.GetByUsername(ctx, "Alice")
if err != nil || gotUpper.ID != upper.ID {
t.Fatalf("Alice lookup = %+v, err = %v", gotUpper, err)
}
gotLower, err := us.GetByUsername(ctx, "alice")
if err != nil || gotLower.ID != lower.ID {
t.Fatalf("alice lookup = %+v, err = %v", gotLower, err)
}
})
t.Run("stored password hash differs from plaintext", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
const plain = "plain-secret-123"
hash, err := auth.HashPassword(plain)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := us.Create(ctx, &models.User{ID: uuid.New().String(), Username: "alice", PasswordHash: hash}); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := us.GetByUsername(ctx, "alice")
if err != nil {
t.Fatalf("GetByUsername: %v", err)
}
if got.PasswordHash == plain {
t.Fatal("stored hash must not equal the plaintext password")
}
if !auth.CheckPassword(plain, got.PasswordHash) {
t.Fatal("stored hash must verify against the original password")
}
})
t.Run("GetByID returns the user and ErrNotFound for unknown id", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
u := mustCreateUser(t, us, "alice")
got, err := us.GetByID(ctx, u.ID)
if err != nil || got.Username != "alice" {
t.Fatalf("GetByID = %+v, err = %v", got, err)
}
if _, err := us.GetByID(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("Create assigns an id when none is given", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
u := &models.User{Username: "noid", PasswordHash: "h"}
if err := us.Create(ctx, u); err != nil {
t.Fatalf("Create: %v", err)
}
if u.ID == "" {
t.Fatal("Create must assign an id")
}
})
}
func TestUserStoreConcurrentRegister(t *testing.T) {
t.Run("20 goroutines register the same username", func(t *testing.T) {
resetDB(t)
us, _, _ := newTestStores()
const n = 20
errs := make([]error, n)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
errs[i] = us.Create(context.Background(), &models.User{
ID: uuid.New().String(),
Username: "same-name",
PasswordHash: "h",
})
}(i)
}
close(start)
wg.Wait()
success, duplicate := 0, 0
for _, err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, ErrUsernameTaken):
duplicate++
default:
t.Fatalf("unexpected error: %v", err)
}
}
if success != 1 || duplicate != n-1 {
t.Fatalf("successes = %d, duplicates = %d; want 1 and %d", success, duplicate, n-1)
}
if rows := countRows(t, "users"); rows != 1 {
t.Fatalf("users rows = %d, want 1", rows)
}
})
}