feat(repository): refactor query/mutation ports with capability-safe
interfaces - refactor: split UserRepository into AuthUserRepository and AdminUserRepository capabilities - refactor: split SessionRepository into AuthSessionRepository and repository-owned CLI/admin methods - refactor: replace generic Repository Update/Delete with operation-specific params and ownership predicates - refactor: replace CredentialRepository generic CRUD with passkey-specific methods - refactor: replace FileRepository generic Create/Update/Delete with UploadedFileParams, DirectoryParams, and owned soft-delete - refactor: remove repository fields from WebApp struct; repositories are now composition-time wiring only - feat: add domain error kinds ErrParentNotFound, ErrParentNotDir, ErrDirectoryNotEmpty, ErrInvalidMove - feat: add CredentialTypeAppPasskey constant - feat: add testutil.SetUserAdmin for test fixture setup that bypasses production service ports - test: add architecture test banning GORM Save in repository package - test: add capability interface contract tests ensuring each service receives the minimal interface - test: add blockingStorage helper for concurrent promotion tests - test: add preserved DSN parameter test for sqliteImmediateDSN - docs: update architecture decisions with repository write rules and capability separation - docs: update roadmap to clarify atomic single-use refresh sessions - docs: add -race test target to development docs
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepositoryCapabilitiesExcludeUnneededOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
capability reflect.Type
|
||||
forbidden []string
|
||||
}{
|
||||
{
|
||||
name: "auth user",
|
||||
capability: reflect.TypeFor[AuthUserRepository](),
|
||||
forbidden: []string{"MarkAdminDeleted", "ListIncludeDeleted"},
|
||||
},
|
||||
{
|
||||
name: "admin user",
|
||||
capability: reflect.TypeFor[AdminUserRepository](),
|
||||
forbidden: []string{"CreateRegisteredUser", "FindByEmail"},
|
||||
},
|
||||
{
|
||||
name: "auth session",
|
||||
capability: reflect.TypeFor[AuthSessionRepository](),
|
||||
forbidden: []string{"DeleteSessionsByUserID", "DeleteExpiredSessions"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, method := range tt.forbidden {
|
||||
if _, ok := tt.capability.MethodByName(method); ok {
|
||||
t.Fatalf("capability unexpectedly exposes %s", method)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,22 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// PasskeyParams contains the fields accepted when creating an app passkey.
|
||||
type PasskeyParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
Label string
|
||||
SecretHash string
|
||||
}
|
||||
|
||||
// CredentialRepository provides access to alternative credential records.
|
||||
type CredentialRepository interface {
|
||||
Create(ctx context.Context, cred *model.Credential) error
|
||||
FindByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error)
|
||||
FindByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
UpdateLastUsed(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
CreatePasskey(ctx context.Context, params PasskeyParams) error
|
||||
FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
RecordPasskeyUsed(ctx context.Context, id string) error
|
||||
RevokeOwnedPasskey(ctx context.Context, userID, id string) error
|
||||
}
|
||||
|
||||
type credentialRepository struct {
|
||||
@@ -30,7 +37,14 @@ func NewCredentialRepository(db *gorm.DB) CredentialRepository {
|
||||
return &credentialRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Create(ctx context.Context, cred *model.Credential) error {
|
||||
func (r *credentialRepository) CreatePasskey(ctx context.Context, params PasskeyParams) error {
|
||||
cred := &model.Credential{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
Type: model.CredentialTypeAppPasskey,
|
||||
Label: params.Label,
|
||||
SecretHash: params.SecretHash,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(cred)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
@@ -41,9 +55,9 @@ func (r *credentialRepository) Create(ctx context.Context, cred *model.Credentia
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
func (r *credentialRepository) FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ?", id)
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ? AND type = ?", id, model.CredentialTypeAppPasskey)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -53,27 +67,18 @@ func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserID(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
func (r *credentialRepository) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&creds)
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, model.CredentialTypeAppPasskey).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, credType).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
func (r *credentialRepository) FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ?", hash)
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ? AND type = ?", hash, model.CredentialTypeAppPasskey)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -83,19 +88,27 @@ func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*mo
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) UpdateLastUsed(ctx context.Context, id string) error {
|
||||
func (r *credentialRepository) RecordPasskeyUsed(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).Where("id = ?", id).Update("last_used_at", now)
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).
|
||||
Where("id = ? AND type = ?", id, model.CredentialTypeAppPasskey).
|
||||
Update("last_used_at", now)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ?", id)
|
||||
func (r *credentialRepository) RevokeOwnedPasskey(ctx context.Context, userID, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ? AND user_id = ? AND type = ?", id, userID, model.CredentialTypeAppPasskey)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ func setupCredentialRepo(t *testing.T) CredentialRepository {
|
||||
return NewCredentialRepository(db)
|
||||
}
|
||||
|
||||
func createPasskeyRecord(ctx context.Context, repo CredentialRepository, cred *model.Credential) error {
|
||||
return repo.CreatePasskey(ctx, PasskeyParams{
|
||||
ID: cred.ID, UserID: cred.UserID, Label: cred.Label, SecretHash: cred.SecretHash,
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialRepository_Create(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +42,7 @@ func TestCredentialRepository_Create(t *testing.T) {
|
||||
SecretHash: "hash-abc",
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +54,11 @@ func TestCredentialRepository_CreateDuplicateHash(t *testing.T) {
|
||||
c1 := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "hash-abc"}
|
||||
c2 := &model.Credential{ID: "cred-2", UserID: "user-1", Type: "app_passkey", Label: "B", SecretHash: "hash-abc"}
|
||||
|
||||
if err := repo.Create(ctx, c1); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, c1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, c2)
|
||||
err := createPasskeyRecord(ctx, repo, c2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,11 +69,11 @@ func TestCredentialRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "cred-1")
|
||||
found, err := repo.FindPasskeyByID(ctx, "cred-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
@@ -80,13 +86,13 @@ func TestCredentialRepository_FindByIDNotFound(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.FindByID(ctx, "nonexistent")
|
||||
_, err := repo.FindPasskeyByID(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
func TestCredentialRepository_ListPasskeys(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -95,12 +101,12 @@ func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
c3 := &model.Credential{ID: "c-3", UserID: "user-2", Type: "app_passkey", Label: "C", SecretHash: "h3"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2, c3} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, c); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
creds, err := repo.FindByUserID(ctx, "user-1")
|
||||
creds, err := repo.ListPasskeys(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserID = %v", err)
|
||||
}
|
||||
@@ -109,28 +115,24 @@ func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserIDAndType(t *testing.T) {
|
||||
func TestCredentialRepository_CreatePasskeyControlsType(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1 := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "h1"}
|
||||
c2 := &model.Credential{ID: "c-2", UserID: "user-1", Type: "oauth", Label: "Github", SecretHash: "h2"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "A", SecretHash: "h1"}
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
passkeys, err := repo.FindByUserIDAndType(ctx, "user-1", "app_passkey")
|
||||
passkeys, err := repo.ListPasskeys(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDAndType = %v", err)
|
||||
t.Fatalf("ListPasskeys = %v", err)
|
||||
}
|
||||
if len(passkeys) != 1 {
|
||||
t.Errorf("len(passkeys) = %d, want 1", len(passkeys))
|
||||
}
|
||||
if passkeys[0].Type != "app_passkey" {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, "app_passkey")
|
||||
if passkeys[0].Type != model.CredentialTypeAppPasskey {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, model.CredentialTypeAppPasskey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +141,11 @@ func TestCredentialRepository_FindByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "hash-find"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByHash(ctx, "hash-find")
|
||||
found, err := repo.FindPasskeyByHash(ctx, "hash-find")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByHash = %v", err)
|
||||
}
|
||||
@@ -157,15 +159,15 @@ func TestCredentialRepository_UpdateLastUsed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateLastUsed(ctx, "c-1"); err != nil {
|
||||
if err := repo.RecordPasskeyUsed(ctx, "c-1"); err != nil {
|
||||
t.Fatalf("UpdateLastUsed = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "c-1")
|
||||
found, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
@@ -179,16 +181,35 @@ func TestCredentialRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "c-1"); err != nil {
|
||||
if err := repo.RevokeOwnedPasskey(ctx, "user-1", "c-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "c-1")
|
||||
_, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_RevokeOwnedPasskeyEnforcesOwner(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "Phone", SecretHash: "h1"}
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.RevokeOwnedPasskey(ctx, "user-2", cred.ID); err != model.ErrNotFound {
|
||||
t.Fatalf("cross-owner revoke = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := repo.FindPasskeyByID(ctx, cred.ID); err != nil {
|
||||
t.Fatalf("cross-owner revoke removed passkey: %v", err)
|
||||
}
|
||||
if err := repo.RevokeOwnedPasskey(ctx, cred.UserID, cred.ID); err != nil {
|
||||
t.Fatalf("owner revoke = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -23,7 +25,7 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
dialector = sqlite.Open(cfg.SQLite.Path)
|
||||
dialector = sqlite.Open(sqliteImmediateDSN(cfg.SQLite.Path))
|
||||
case "postgres":
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s",
|
||||
@@ -47,6 +49,20 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func sqliteImmediateDSN(path string) string {
|
||||
base, rawQuery, found := strings.Cut(path, "?")
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
separator := "?"
|
||||
if found {
|
||||
separator = "&"
|
||||
}
|
||||
return path + separator + "_txlock=immediate"
|
||||
}
|
||||
query.Set("_txlock", "immediate")
|
||||
return base + "?" + query.Encode()
|
||||
}
|
||||
|
||||
// AutoMigrate runs schema migration for all domain models.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
func TestSQLiteImmediateDSNPreservesParameters(t *testing.T) {
|
||||
dsn := sqliteImmediateDSN("file:test.db?cache=shared&_busy_timeout=9000&_txlock=deferred")
|
||||
base, rawQuery, found := strings.Cut(dsn, "?")
|
||||
if !found || base != "file:test.db" {
|
||||
t.Fatalf("DSN base = %q, want file:test.db", base)
|
||||
}
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DSN query: %v", err)
|
||||
}
|
||||
if query.Get("cache") != "shared" || query.Get("_busy_timeout") != "9000" {
|
||||
t.Fatalf("DSN did not preserve parameters: %q", dsn)
|
||||
}
|
||||
if query.Get("_txlock") != "immediate" {
|
||||
t.Fatalf("_txlock = %q, want immediate", query.Get("_txlock"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSQLite(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
|
||||
+226
-27
@@ -3,22 +3,54 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// UploadedFileParams contains persisted metadata for a completed upload.
|
||||
type UploadedFileParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
Size int64
|
||||
MimeType string
|
||||
StoragePath string
|
||||
Hash string
|
||||
}
|
||||
|
||||
// DirectoryParams contains the fields accepted when creating a directory.
|
||||
type DirectoryParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
}
|
||||
|
||||
// FileMetadataUpdate contains the user-editable metadata fields.
|
||||
// Nil fields are left unchanged.
|
||||
type FileMetadataUpdate struct {
|
||||
FileID string
|
||||
UserID string
|
||||
NewName *string
|
||||
NewParentID *string
|
||||
}
|
||||
|
||||
// FileRepository provides access to file records.
|
||||
type FileRepository interface {
|
||||
Create(ctx context.Context, file *model.File) error
|
||||
CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error)
|
||||
CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error)
|
||||
FindByID(ctx context.Context, id string) (*model.File, error)
|
||||
FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error)
|
||||
FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error)
|
||||
Update(ctx context.Context, file *model.File) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error)
|
||||
SoftDeleteOwned(ctx context.Context, userID, fileID string) error
|
||||
}
|
||||
|
||||
type fileRepository struct {
|
||||
@@ -30,15 +62,65 @@ func NewFileRepository(db *gorm.DB) FileRepository {
|
||||
return &fileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Create(file)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) {
|
||||
file := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Size: params.Size,
|
||||
MimeType: params.MimeType,
|
||||
StoragePath: params.StoragePath,
|
||||
Hash: params.Hash,
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
return nil
|
||||
if err := r.createEntry(ctx, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) {
|
||||
dir := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
if err := r.createEntry(ctx, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) createEntry(ctx context.Context, file *model.File) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if file.ParentID != nil {
|
||||
locked, err := lockActiveOwnedFiles(tx, file.UserID, []string{*file.ParentID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent, ok := locked[*file.ParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Create(file)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
|
||||
@@ -106,24 +188,141 @@ func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string,
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Save(file)
|
||||
if result.Error != nil {
|
||||
func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) {
|
||||
var updated model.File
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := []string{params.FileID}
|
||||
if params.NewParentID != nil {
|
||||
if *params.NewParentID == params.FileID {
|
||||
return model.ErrInvalidMove
|
||||
}
|
||||
ids = append(ids, *params.NewParentID)
|
||||
}
|
||||
|
||||
locked, err := lockActiveOwnedFiles(tx, params.UserID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, ok := locked[params.FileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 2)
|
||||
finalName := source.Name
|
||||
finalParentID := source.ParentID
|
||||
if params.NewName != nil {
|
||||
updates["name"] = *params.NewName
|
||||
finalName = *params.NewName
|
||||
}
|
||||
if params.NewParentID != nil {
|
||||
parent, ok := locked[*params.NewParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
updates["parent_id"] = *params.NewParentID
|
||||
finalParentID = params.NewParentID
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
var conflictCount int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ? AND id <> ?", params.UserID, finalParentID, finalName, model.StatusActive, params.FileID).
|
||||
Count(&conflictCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if conflictCount > 0 {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", params.FileID, params.UserID, model.StatusActive).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.First(&updated, "id = ? AND user_id = ? AND status = ?", source.ID, params.UserID, model.StatusActive)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return result.Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&model.File{}).
|
||||
Where("id = ? AND status = ?", id, model.StatusActive).
|
||||
Update("status", model.StatusUserDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, ok := locked[fileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
var count int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("parent_id = ? AND status = ?", fileID, model.StatusActive).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return model.ErrDirectoryNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", fileID, userID, model.StatusActive).
|
||||
Update("status", model.StatusUserDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func lockActiveOwnedFiles(tx *gorm.DB, userID string, ids []string) (map[string]model.File, error) {
|
||||
unique := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
unique[id] = struct{}{}
|
||||
}
|
||||
ordered := make([]string, 0, len(unique))
|
||||
for id := range unique {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
|
||||
var files []model.File
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
Where("id IN ? AND user_id = ? AND status = ?", ordered, userID, model.StatusActive).
|
||||
Order("id ASC").
|
||||
Find(&files)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
locked := make(map[string]model.File, len(files))
|
||||
for i := range files {
|
||||
locked[files[i].ID] = files[i]
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,48 @@ func setupFileRepo(t *testing.T) FileRepository {
|
||||
return NewFileRepository(db)
|
||||
}
|
||||
|
||||
func createFileRecord(ctx context.Context, repo FileRepository, file *model.File) error {
|
||||
var err error
|
||||
if file.IsDir {
|
||||
_, err = repo.CreateDirectory(ctx, DirectoryParams{
|
||||
ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name,
|
||||
})
|
||||
} else {
|
||||
_, err = repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name,
|
||||
Size: file.Size, MimeType: file.MimeType, StoragePath: file.StoragePath, Hash: file.Hash,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file.Status == model.StatusUserDeleted {
|
||||
return repo.SoftDeleteOwned(ctx, file.UserID, file.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupConcurrentFileRepo(t *testing.T) FileRepository {
|
||||
t.Helper()
|
||||
db, err := Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "concurrency.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return NewFileRepository(db)
|
||||
}
|
||||
|
||||
func TestFileRepository_Create(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -37,7 +82,7 @@ func TestFileRepository_Create(t *testing.T) {
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +97,7 @@ func TestFileRepository_FindByID(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -85,7 +130,7 @@ func TestFileRepository_FindByUserID(t *testing.T) {
|
||||
{ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -107,13 +152,16 @@ func TestFileRepository_FindByParentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parentID := "dir-1"
|
||||
if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil {
|
||||
t.Fatalf("Create parent = %v", err)
|
||||
}
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive},
|
||||
{ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -132,12 +180,15 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parentID := "dir-1"
|
||||
if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil {
|
||||
t.Fatalf("Create parent = %v", err)
|
||||
}
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -146,8 +197,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("FindByParentID(nil) = %v", err)
|
||||
}
|
||||
if len(children) != 1 {
|
||||
t.Errorf("len(children) = %d, want 1", len(children))
|
||||
if len(children) != 2 {
|
||||
t.Errorf("len(children) = %d, want 2", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,14 +206,26 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
file := &model.File{
|
||||
ID: "file-1",
|
||||
UserID: "user-1",
|
||||
Name: "original.txt",
|
||||
Size: 1024,
|
||||
MimeType: "text/plain",
|
||||
StoragePath: "data/user-1/file-1",
|
||||
Hash: "original-hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
before, err := repo.FindByID(ctx, file.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID before update = %v", err)
|
||||
}
|
||||
|
||||
file.Name = "renamed.txt"
|
||||
file.Size = 2048
|
||||
if err := repo.Update(ctx, file); err != nil {
|
||||
newName := "renamed.txt"
|
||||
if _, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName}); err != nil {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
|
||||
@@ -173,8 +236,11 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
if found.Name != "renamed.txt" {
|
||||
t.Errorf("name = %q, want %q", found.Name, "renamed.txt")
|
||||
}
|
||||
if found.Size != 2048 {
|
||||
t.Errorf("size = %d, want %d", found.Size, 2048)
|
||||
if found.UserID != before.UserID || found.Status != before.Status || found.IsDir != before.IsDir ||
|
||||
found.Size != before.Size || found.MimeType != before.MimeType ||
|
||||
found.StoragePath != before.StoragePath || found.Hash != before.Hash ||
|
||||
!found.CreatedAt.Equal(before.CreatedAt) {
|
||||
t.Fatalf("metadata update changed protected fields: before=%+v after=%+v", before, found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +249,11 @@ func TestFileRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -207,11 +273,11 @@ func TestFileRepository_SoftDelete(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -238,10 +304,10 @@ func TestFileRepository_StatusFilter(t *testing.T) {
|
||||
Status: model.StatusUserDeleted,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, activeFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, activeFile); err != nil {
|
||||
t.Fatalf("Create active = %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deletedFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, deletedFile); err != nil {
|
||||
t.Fatalf("Create deleted = %v", err)
|
||||
}
|
||||
|
||||
@@ -270,17 +336,17 @@ func TestFileRepository_DeleteReturnsNotFoundAfterDeletion(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("second Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "missing-file"); !errors.Is(err, model.ErrNotFound) {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "missing-file"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("missing Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -302,10 +368,10 @@ func TestFileRepository_StatusFilterCount(t *testing.T) {
|
||||
Status: model.StatusUserDeleted,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, activeFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, activeFile); err != nil {
|
||||
t.Fatalf("Create active = %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deletedFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, deletedFile); err != nil {
|
||||
t.Fatalf("Create deleted = %v", err)
|
||||
}
|
||||
|
||||
@@ -317,3 +383,160 @@ func TestFileRepository_StatusFilterCount(t *testing.T) {
|
||||
t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_UpdateCannotReviveDeletedFile(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "before.txt", Status: model.StatusActive}
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
if err := repo.SoftDeleteOwned(ctx, file.UserID, file.ID); err != nil {
|
||||
t.Fatalf("SoftDeleteOwned = %v", err)
|
||||
}
|
||||
|
||||
newName := "after.txt"
|
||||
_, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName})
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("UpdateOwnedMetadata = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, file.ID); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("deleted file became active again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_ConcurrentUpdateAndDeleteCannotReviveFile(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: "file-1", UserID: "user-1", Name: "before.txt", StoragePath: "data/user-1/file-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateUploadedFile = %v", err)
|
||||
}
|
||||
|
||||
newName := "after.txt"
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var updateErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, updateErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{
|
||||
FileID: "file-1", UserID: "user-1", NewName: &newName,
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", "file-1")
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if deleteErr != nil {
|
||||
t.Fatalf("SoftDeleteOwned = %v, want success", deleteErr)
|
||||
}
|
||||
if updateErr != nil && !errors.Is(updateErr, model.ErrNotFound) {
|
||||
t.Fatalf("UpdateOwnedMetadata = %v, want success or ErrNotFound", updateErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("concurrent update revived deleted file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_CreateAndParentDeleteAreSerialized(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
dir, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "dir-1", UserID: "user-1", Name: "dir"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var createErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, createErr = repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: "child-1", UserID: "user-1", ParentID: &dir.ID, Name: "child.txt", StoragePath: "data/user-1/child-1",
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", dir.ID)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
switch {
|
||||
case createErr == nil:
|
||||
if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) {
|
||||
t.Fatalf("create succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, dir.ID); err != nil {
|
||||
t.Fatalf("parent missing after child creation: %v", err)
|
||||
}
|
||||
case deleteErr == nil:
|
||||
if !errors.Is(createErr, model.ErrParentNotFound) {
|
||||
t.Fatalf("delete succeeded but create error = %v, want ErrParentNotFound", createErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, "child-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("active orphan child exists: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("neither operation succeeded: create=%v delete=%v", createErr, deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_MoveAndTargetDeleteAreSerialized(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
target, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "target-1", UserID: "user-1", Name: "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory = %v", err)
|
||||
}
|
||||
if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{ID: "file-1", UserID: "user-1", Name: "file.txt", StoragePath: "data/user-1/file-1"}); err != nil {
|
||||
t.Fatalf("CreateUploadedFile = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var moveErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, moveErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: "file-1", UserID: "user-1", NewParentID: &target.ID})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", target.ID)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
switch {
|
||||
case moveErr == nil:
|
||||
if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) {
|
||||
t.Fatalf("move succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr)
|
||||
}
|
||||
case deleteErr == nil:
|
||||
if !errors.Is(moveErr, model.ErrParentNotFound) {
|
||||
t.Fatalf("delete succeeded but move error = %v, want ErrParentNotFound", moveErr)
|
||||
}
|
||||
file, err := repo.FindByID(ctx, "file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("find source file: %v", err)
|
||||
}
|
||||
if file.ParentID != nil {
|
||||
t.Fatalf("failed move changed source parent to %v", *file.ParentID)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("neither operation succeeded: move=%v delete=%v", moveErr, deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,33 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// SessionRepository provides access to refresh token sessions.
|
||||
// RefreshSessionParams contains the fields persisted for a refresh session.
|
||||
type RefreshSessionParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthSessionRepository exposes only session operations required by AuthService.
|
||||
type AuthSessionRepository interface {
|
||||
CreateRefreshSession(ctx context.Context, params RefreshSessionParams) error
|
||||
ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||
RevokeRefreshSession(ctx context.Context, tokenHash string) error
|
||||
}
|
||||
|
||||
// SessionRepository is the composition-time union of session capabilities.
|
||||
type SessionRepository interface {
|
||||
Create(ctx context.Context, session *model.Session) error
|
||||
AuthSessionRepository
|
||||
FindByID(ctx context.Context, id string) (*model.Session, error)
|
||||
FindByTokenHash(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
DeleteByUserID(ctx context.Context, userID string) error
|
||||
DeleteExpired(ctx context.Context) (int64, error)
|
||||
DeleteSessionsByUserID(ctx context.Context, userID string) error
|
||||
DeleteExpiredSessions(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type sessionRepository struct {
|
||||
@@ -29,7 +44,13 @@ func NewSessionRepository(db *gorm.DB) SessionRepository {
|
||||
return &sessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Create(ctx context.Context, session *model.Session) error {
|
||||
func (r *sessionRepository) CreateRefreshSession(ctx context.Context, params RefreshSessionParams) error {
|
||||
session := &model.Session{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
TokenHash: params.TokenHash,
|
||||
ExpiresAt: params.ExpiresAt,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(session)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
@@ -64,15 +85,39 @@ func (r *sessionRepository) FindByTokenHash(ctx context.Context, tokenHash strin
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "id = ?", id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
func (r *sessionRepository) ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error) {
|
||||
var consumed model.Session
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&consumed, "token_hash = ?", tokenHash)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
result = tx.Delete(&model.Session{}, "id = ?", consumed.ID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return &consumed, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) error {
|
||||
func (r *sessionRepository) RevokeRefreshSession(ctx context.Context, tokenHash string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "token_hash = ?", tokenHash)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteSessionsByUserID(ctx context.Context, userID string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "user_id = ?", userID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -80,7 +125,7 @@ func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteExpired(ctx context.Context) (int64, error) {
|
||||
func (r *sessionRepository) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "expires_at < ?", time.Now())
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
|
||||
@@ -2,12 +2,16 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,12 @@ func setupSessionRepo(t *testing.T) SessionRepository {
|
||||
return NewSessionRepository(db)
|
||||
}
|
||||
|
||||
func createSessionRecord(ctx context.Context, repo SessionRepository, session *model.Session) error {
|
||||
return repo.CreateRefreshSession(ctx, RefreshSessionParams{
|
||||
ID: session.ID, UserID: session.UserID, TokenHash: session.TokenHash, ExpiresAt: session.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionRepository_Create(t *testing.T) {
|
||||
repo := setupSessionRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +46,7 @@ func TestSessionRepository_Create(t *testing.T) {
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +58,11 @@ func TestSessionRepository_CreateDuplicateHash(t *testing.T) {
|
||||
s1 := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
s2 := &model.Session{ID: "session-2", UserID: "user-2", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
if err := repo.Create(ctx, s1); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, s2)
|
||||
err := createSessionRecord(ctx, repo, s2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,7 +73,7 @@ func TestSessionRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -81,7 +91,7 @@ func TestSessionRepository_FindByTokenHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -109,15 +119,19 @@ func TestSessionRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "session-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
consumed, err := repo.ConsumeRefreshSession(ctx, "hash-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeRefreshSession = %v", err)
|
||||
}
|
||||
if consumed.ID != "session-1" {
|
||||
t.Fatalf("consumed ID = %q, want session-1", consumed.ID)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "session-1")
|
||||
_, err = repo.FindByID(ctx, "session-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
@@ -132,12 +146,12 @@ func TestSessionRepository_DeleteByUserID(t *testing.T) {
|
||||
s3 := &model.Session{ID: "session-3", UserID: "user-2", TokenHash: "hash-3", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
for _, s := range []*model.Session{s1, s2, s3} {
|
||||
if err := repo.Create(ctx, s); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.DeleteByUserID(ctx, "user-1"); err != nil {
|
||||
if err := repo.DeleteSessionsByUserID(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("DeleteByUserID = %v", err)
|
||||
}
|
||||
|
||||
@@ -168,12 +182,12 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range []*model.Session{expired, valid} {
|
||||
if err := repo.Create(ctx, s); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := repo.DeleteExpired(ctx)
|
||||
count, err := repo.DeleteExpiredSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpired = %v", err)
|
||||
}
|
||||
@@ -188,3 +202,55 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
||||
t.Fatalf("valid session should still exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRepository_ConsumeRefreshSessionIsAtomic(t *testing.T) {
|
||||
db, err := Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "sessions.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repo := NewSessionRepository(db)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateRefreshSession(ctx, RefreshSessionParams{
|
||||
ID: "session-1", UserID: "user-1", TokenHash: "single-use", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRefreshSession = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := repo.ConsumeRefreshSession(ctx, "single-use")
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
succeeded := 0
|
||||
notFound := 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded++
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
notFound++
|
||||
default:
|
||||
t.Fatalf("ConsumeRefreshSession unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if succeeded != 1 || notFound != 1 {
|
||||
t.Fatalf("consume results: success=%d not_found=%d, want 1 each", succeeded, notFound)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-24
@@ -15,19 +15,37 @@ func isDuplicateKeyError(err error) bool {
|
||||
return errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||
}
|
||||
|
||||
// UserRepository provides access to user records.
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *model.User) error
|
||||
// RegisteredUserParams contains the fields accepted when registering a user.
|
||||
// Administrative and lifecycle fields are intentionally excluded.
|
||||
type RegisteredUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// AuthUserRepository exposes only user operations required by authentication.
|
||||
type AuthUserRepository interface {
|
||||
CreateRegisteredUser(ctx context.Context, params RegisteredUserParams) (*model.User, error)
|
||||
FindByID(ctx context.Context, id string) (*model.User, error)
|
||||
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
Update(ctx context.Context, user *model.User) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
// AdminUserRepository exposes only user operations required by administration.
|
||||
type AdminUserRepository interface {
|
||||
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
|
||||
MarkAdminDeleted(ctx context.Context, id string) error
|
||||
ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
// UserRepository is the composition-time union of user capabilities.
|
||||
type UserRepository interface {
|
||||
AuthUserRepository
|
||||
AdminUserRepository
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -37,15 +55,23 @@ func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *model.User) error {
|
||||
func (r *userRepository) CreateRegisteredUser(ctx context.Context, params RegisteredUserParams) (*model.User, error) {
|
||||
user := &model.User{
|
||||
ID: params.ID,
|
||||
Username: params.Username,
|
||||
Email: params.Email,
|
||||
PasswordHash: params.PasswordHash,
|
||||
IsAdmin: false,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(user)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
return nil, model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
return nil, result.Error
|
||||
}
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||
@@ -97,18 +123,7 @@ func (r *userRepository) FindByUsername(ctx context.Context, username string) (*
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
|
||||
result := r.db.WithContext(ctx).Save(user)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||
func (r *userRepository) MarkAdminDeleted(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
||||
@@ -24,6 +24,16 @@ func setupUserRepo(t *testing.T) UserRepository {
|
||||
return NewUserRepository(db)
|
||||
}
|
||||
|
||||
func createUserRecord(ctx context.Context, repo UserRepository, user *model.User) error {
|
||||
_, err := repo.CreateRegisteredUser(ctx, RegisteredUserParams{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
PasswordHash: user.PasswordHash,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func TestUserRepository_Create(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +46,7 @@ func TestUserRepository_Create(t *testing.T) {
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +58,11 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) {
|
||||
u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, u2)
|
||||
err := createUserRecord(ctx, repo, u2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,7 +73,7 @@ func TestUserRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -91,7 +101,7 @@ func TestUserRepository_FindByEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -109,7 +119,7 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -122,26 +132,24 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_Update(t *testing.T) {
|
||||
func TestUserRepository_CreateRegisteredUserControlsPrivilegedFields(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
user.Username = "alice2"
|
||||
if err := repo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
if found.Username != "alice2" {
|
||||
t.Errorf("username = %q, want %q", found.Username, "alice2")
|
||||
if found.IsAdmin {
|
||||
t.Error("registered user must not be an administrator")
|
||||
}
|
||||
if found.Status != model.StatusActive {
|
||||
t.Errorf("status = %q, want %q", found.Status, model.StatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +158,11 @@ func TestUserRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -176,7 +184,7 @@ func TestUserRepository_List(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -204,11 +212,11 @@ func TestUserRepository_SoftDelete(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -231,11 +239,11 @@ func TestUserRepository_DisabledLogin(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -258,7 +266,7 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u1); err != nil {
|
||||
t.Fatalf("Create u1 = %v", err)
|
||||
}
|
||||
|
||||
@@ -269,12 +277,12 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, u2); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u2); err != nil {
|
||||
t.Fatalf("Create u2 = %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete bob
|
||||
if err := repo.Delete(ctx, "user-2"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-2"); err != nil {
|
||||
t.Fatalf("Delete u2 = %v", err)
|
||||
}
|
||||
|
||||
@@ -305,15 +313,15 @@ func TestUserRepository_DeleteIdempotent(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete the same user twice should not error.
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("second Delete = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user