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:
2026-07-16 12:24:36 +08:00
parent 6604ecb026
commit a18f96912d
30 changed files with 1259 additions and 394 deletions
+3 -3
View File
@@ -10,11 +10,11 @@ import (
// AdminService handles administrator user-management operations.
type AdminService struct {
userRepo repository.UserRepository
userRepo repository.AdminUserRepository
}
// NewAdminService creates an AdminService.
func NewAdminService(userRepo repository.UserRepository) *AdminService {
func NewAdminService(userRepo repository.AdminUserRepository) *AdminService {
return &AdminService{userRepo: userRepo}
}
@@ -44,7 +44,7 @@ func (s *AdminService) DeleteUser(ctx context.Context, id string) error {
if _, err := s.GetUser(ctx, id); err != nil {
return err
}
if err := s.userRepo.Delete(ctx, id); err != nil {
if err := s.userRepo.MarkAdminDeleted(ctx, id); err != nil {
return model.NewInternalError("delete admin user", err)
}
return nil
+3 -3
View File
@@ -46,13 +46,13 @@ func TestAdminServiceListIncludesDeletedUsers(t *testing.T) {
active := &model.User{ID: "active-1", Username: "active", Email: "active@example.com", Status: model.StatusActive}
deleted := &model.User{ID: "deleted-1", Username: "deleted", Email: "deleted@example.com", Status: model.StatusActive}
if err := repo.Create(ctx, active); err != nil {
if _, err := repo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{ID: active.ID, Username: active.Username, Email: active.Email, PasswordHash: "hash"}); err != nil {
t.Fatalf("create active: %v", err)
}
if err := repo.Create(ctx, deleted); err != nil {
if _, err := repo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{ID: deleted.ID, Username: deleted.Username, Email: deleted.Email, PasswordHash: "hash"}); err != nil {
t.Fatalf("create deleted: %v", err)
}
if err := repo.Delete(ctx, deleted.ID); err != nil {
if err := repo.MarkAdminDeleted(ctx, deleted.ID); err != nil {
t.Fatalf("delete user: %v", err)
}
+31 -44
View File
@@ -34,8 +34,8 @@ type Principal struct {
// AuthService handles user authentication and session management.
type AuthService struct {
userRepo repository.UserRepository
sessionRepo repository.SessionRepository
userRepo repository.AuthUserRepository
sessionRepo repository.AuthSessionRepository
credentialRepo repository.CredentialRepository
jwtSecret []byte
accessTTL time.Duration
@@ -44,8 +44,8 @@ type AuthService struct {
// NewAuthService creates an AuthService.
func NewAuthService(
userRepo repository.UserRepository,
sessionRepo repository.SessionRepository,
userRepo repository.AuthUserRepository,
sessionRepo repository.AuthSessionRepository,
credentialRepo repository.CredentialRepository,
jwtSecret []byte,
accessTTL time.Duration,
@@ -72,15 +72,13 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
return nil, model.NewInternalError("hash password", err)
}
user := &model.User{
user, err := s.userRepo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{
ID: uuid.NewString(),
Username: username,
Email: email,
PasswordHash: passwordHash,
Status: model.StatusActive,
}
if err := s.userRepo.Create(ctx, user); err != nil {
})
if err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("username or email already exists")
}
@@ -124,7 +122,7 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
}
tokenHash := auth.HashToken(refreshTokenStr)
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
session, err := s.sessionRepo.ConsumeRefreshSession(ctx, tokenHash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewUnauthenticatedError("invalid token")
@@ -144,25 +142,13 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
return nil, model.NewUnauthenticatedError("invalid token")
}
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
return nil, model.NewInternalError("delete old session", err)
}
return s.issueTokens(ctx, claims.UserID)
}
// Logout invalidates a refresh token by deleting its session.
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
tokenHash := auth.HashToken(refreshTokenStr)
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil
}
return model.NewInternalError("find session", err)
}
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
if err := s.sessionRepo.RevokeRefreshSession(ctx, tokenHash); err != nil {
return model.NewInternalError("delete session", err)
}
return nil
@@ -175,20 +161,18 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
return nil, model.NewInternalError("generate token", err)
}
cred := &model.Credential{
ID: uuid.NewString(),
credID := uuid.NewString()
if err := s.credentialRepo.CreatePasskey(ctx, repository.PasskeyParams{
ID: credID,
UserID: userID,
Type: "app_passkey",
Label: label,
SecretHash: hash,
}
if err := s.credentialRepo.Create(ctx, cred); err != nil {
}); err != nil {
return nil, model.NewInternalError("create credential", err)
}
return &CreatedPasskey{
ID: cred.ID,
ID: credID,
Raw: raw,
Label: label,
}, nil
@@ -201,7 +185,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
}
tokenHash := auth.HashToken(tokenStr)
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
cred, err := s.credentialRepo.FindPasskeyByHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewUnauthenticatedError("invalid passkey")
@@ -209,19 +193,21 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
return nil, model.NewInternalError("find credential", err)
}
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
user, err := s.userRepo.FindByID(ctx, cred.UserID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewUnauthenticatedError("invalid passkey")
}
return nil, model.NewInternalError("find user", err)
}
if user.Status != model.StatusActive {
return nil, model.NewUnauthenticatedError("invalid passkey")
}
if cred.Type != "app_passkey" {
return nil, model.NewUnauthenticatedError("invalid credential type")
}
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
if err := s.credentialRepo.RecordPasskeyUsed(ctx, cred.ID); err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewUnauthenticatedError("invalid passkey")
}
return nil, model.NewInternalError("update last used", err)
}
@@ -230,7 +216,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
// ListPasskeys returns all app passkeys for a user.
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
creds, err := s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
creds, err := s.credentialRepo.ListPasskeys(ctx, userID)
if err != nil {
return nil, model.NewInternalError("list passkeys", err)
}
@@ -239,7 +225,7 @@ func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.
// RevokePasskey deletes an app passkey owned by the user.
func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error {
cred, err := s.credentialRepo.FindByID(ctx, credID)
cred, err := s.credentialRepo.FindPasskeyByID(ctx, credID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
@@ -251,7 +237,10 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
}
if err := s.credentialRepo.Delete(ctx, credID); err != nil {
if err := s.credentialRepo.RevokeOwnedPasskey(ctx, userID, credID); err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
}
return model.NewInternalError("delete credential", err)
}
return nil
@@ -296,14 +285,12 @@ func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPai
return nil, model.NewInternalError("generate refresh token", err)
}
session := &model.Session{
if err := s.sessionRepo.CreateRefreshSession(ctx, repository.RefreshSessionParams{
ID: uuid.NewString(),
UserID: userID,
TokenHash: auth.HashToken(refreshToken),
ExpiresAt: time.Now().Add(s.refreshTTL),
}
if err := s.sessionRepo.Create(ctx, session); err != nil {
}); err != nil {
return nil, model.NewInternalError("create session", err)
}
+88 -18
View File
@@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
@@ -10,18 +12,20 @@ import (
"gorm.io/gorm"
"github.com/dhao2001/mygo/internal/auth"
"github.com/dhao2001/mygo/internal/config"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/testutil"
)
func setupAuthService(t *testing.T) *AuthService {
svc, _ := setupAuthServiceWithRepos(t)
svc, _, _ := setupAuthServiceWithRepos(t)
return svc
}
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
// for tests that need direct repo access (e.g., soft-deleting users).
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) {
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -42,7 +46,7 @@ func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepos
15*time.Minute,
7*24*time.Hour,
)
return svc, userRepo
return svc, userRepo, db
}
func TestAuthService_Register(t *testing.T) {
@@ -413,7 +417,7 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
}
func TestAuthService_LoginDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
svc, userRepo, _ := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
@@ -421,7 +425,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
t.Fatalf("Register = %v", err)
}
if err := userRepo.Delete(ctx, user.ID); err != nil {
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
@@ -442,7 +446,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
}
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
svc, userRepo, _ := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
@@ -454,7 +458,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
// Soft-delete user, then try to login
if err := userRepo.Delete(ctx, user.ID); err != nil {
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
@@ -482,7 +486,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
}
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
svc, userRepo, _ := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
@@ -506,7 +510,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
@@ -527,7 +531,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
}
func TestAuthService_RefreshDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
svc, userRepo, _ := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
@@ -541,7 +545,7 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
@@ -562,17 +566,14 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
}
func TestAuthService_AuthenticateAccessToken(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
svc, _, db := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
user.IsAdmin = true
if err := userRepo.Update(ctx, user); err != nil {
t.Fatalf("promote user: %v", err)
}
testutil.SetUserAdmin(t, db, user.ID, true)
pair, err := svc.Login(ctx, "alice@example.com", "password123")
if err != nil {
@@ -592,7 +593,7 @@ func TestAuthService_AuthenticateAccessToken(t *testing.T) {
}
func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
svc, userRepo, _ := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
@@ -605,7 +606,7 @@ func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
t.Fatalf("Login = %v", err)
}
if err := userRepo.Delete(ctx, user.ID); err != nil {
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
@@ -618,3 +619,72 @@ func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
}
}
func TestAuthService_RefreshTokenConcurrentUseAllowsOneSuccess(t *testing.T) {
db, err := repository.Open(config.DatabaseConfig{
Driver: "sqlite3",
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "auth.db")},
})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := repository.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := NewAuthService(
repository.NewUserRepository(db),
repository.NewSessionRepository(db),
repository.NewCredentialRepository(db),
[]byte("test-secret"),
15*time.Minute,
7*24*time.Hour,
)
ctx := context.Background()
if _, err := svc.Register(ctx, "alice", "alice@example.com", "password123"); err != nil {
t.Fatalf("Register = %v", err)
}
pair, err := svc.Login(ctx, "alice@example.com", "password123")
if err != nil {
t.Fatalf("Login = %v", err)
}
type refreshResult struct {
pair *TokenPair
err error
}
start := make(chan struct{})
results := make(chan refreshResult, 2)
var wg sync.WaitGroup
wg.Add(2)
for range 2 {
go func() {
defer wg.Done()
<-start
refreshed, err := svc.Refresh(ctx, pair.RefreshToken)
results <- refreshResult{pair: refreshed, err: err}
}()
}
close(start)
wg.Wait()
close(results)
succeeded := 0
rejected := 0
for result := range results {
if result.err == nil {
succeeded++
if result.pair == nil || result.pair.AccessToken == "" || result.pair.RefreshToken == "" {
t.Fatal("successful refresh returned an empty token pair")
}
continue
}
var appErr *model.AppError
if !errors.As(result.err, &appErr) || appErr.Kind != model.KindUnauthenticated {
t.Fatalf("concurrent refresh error = %v, want unauthenticated", result.err)
}
rejected++
}
if succeeded != 1 || rejected != 1 {
t.Fatalf("refresh results: success=%d rejected=%d, want 1 each", succeeded, rejected)
}
}
+43 -52
View File
@@ -136,7 +136,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
return nil, model.NewInternalError("promote staged file", err)
}
file := &model.File{
file, err := s.fileRepo.CreateUploadedFile(ctx, repository.UploadedFileParams{
ID: fileID,
UserID: userID,
ParentID: parentID,
@@ -145,16 +145,19 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
MimeType: mimeType,
StoragePath: storagePath,
Hash: hex.EncodeToString(hasher.Sum(nil)),
Status: model.StatusActive,
IsDir: false,
}
if err := s.fileRepo.Create(ctx, file); err != nil {
})
if err != nil {
// Compensate: remove the promoted file.
_ = s.storage.Delete(ctx, storagePath)
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("a file with this name already exists in this directory")
}
if errors.Is(err, model.ErrParentNotFound) {
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
}
if errors.Is(err, model.ErrParentNotDir) {
return nil, model.NewBadRequestError("parent is not a directory")
}
return nil, model.NewInternalError("create file record", err)
}
@@ -256,44 +259,42 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string,
// Update renames or moves a file or directory.
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return nil, err
}
// Validate new name if provided.
var newNamePtr *string
if newName != "" {
if err := validateFileName(newName); err != nil {
return nil, err
}
file.Name = newName
newNamePtr = &newName
}
// If parent is changing, verify the new parent.
if newParentID != nil {
if *newParentID == fileID {
return nil, model.NewBadRequestError("cannot move a directory into itself")
}
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
return nil, err
}
file.ParentID = newParentID
}
// Check for name conflicts in the target directory.
if newName != "" || newParentID != nil {
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
if err == nil && conflict.ID != fileID {
return nil, model.NewConflictError("a file with this name already exists in the target directory")
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err)
}
}
if err := s.fileRepo.Update(ctx, file); err != nil {
file, err := s.fileRepo.UpdateOwnedMetadata(ctx, repository.FileMetadataUpdate{
FileID: fileID,
UserID: userID,
NewName: newNamePtr,
NewParentID: newParentID,
})
if err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("a file with this name already exists in the target directory")
}
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
}
if errors.Is(err, model.ErrParentNotFound) {
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
}
if errors.Is(err, model.ErrParentNotDir) {
return nil, model.NewBadRequestError("parent is not a directory")
}
if errors.Is(err, model.ErrInvalidMove) {
return nil, model.NewBadRequestError("cannot move a directory into itself")
}
return nil, model.NewInternalError("update file", err)
}
@@ -302,26 +303,13 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return err
}
// Directories must be empty before deletion.
if file.IsDir {
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
if err != nil {
return model.NewInternalError("check directory contents", err)
}
if total > 0 {
return model.NewConflictError("directory is not empty")
}
}
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
if err := s.fileRepo.SoftDeleteOwned(ctx, userID, fileID); err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.NewNotFoundError("file not found", model.ErrNotFound)
}
if errors.Is(err, model.ErrDirectoryNotEmpty) {
return model.NewConflictError("directory is not empty")
}
return model.NewInternalError("delete file record", err)
}
@@ -347,19 +335,22 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
return nil, model.NewInternalError("check name conflict", err)
}
dir := &model.File{
dir, err := s.fileRepo.CreateDirectory(ctx, repository.DirectoryParams{
ID: uuid.NewString(),
UserID: userID,
ParentID: parentID,
Name: dirName,
Status: model.StatusActive,
IsDir: true,
}
if err := s.fileRepo.Create(ctx, dir); err != nil {
})
if err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("a file with this name already exists in this directory")
}
if errors.Is(err, model.ErrParentNotFound) {
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
}
if errors.Is(err, model.ErrParentNotDir) {
return nil, model.NewBadRequestError("parent is not a directory")
}
return nil, model.NewInternalError("create directory record", err)
}
+90
View File
@@ -50,6 +50,33 @@ type memStorage struct {
files map[string][]byte
}
type blockingPromoteStorage struct {
*memStorage
promoted chan struct{}
release chan struct{}
}
func newBlockingPromoteStorage() *blockingPromoteStorage {
return &blockingPromoteStorage{
memStorage: newMemStorage(),
promoted: make(chan struct{}),
release: make(chan struct{}),
}
}
func (s *blockingPromoteStorage) PromoteStaged(ctx context.Context, stagedPath, finalPath string) error {
if err := s.memStorage.PromoteStaged(ctx, stagedPath, finalPath); err != nil {
return err
}
close(s.promoted)
select {
case <-s.release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func newMemStorage() *memStorage {
return &memStorage{files: make(map[string][]byte)}
}
@@ -407,6 +434,30 @@ func TestFileService_Update(t *testing.T) {
}
}
func TestFileService_UpdateRejectsDuplicateNameInRoot(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
if _, err := svc.Upload(ctx, "user1", nil, "existing.txt", strings.NewReader("first")); err != nil {
t.Fatalf("Upload existing = %v", err)
}
file, err := svc.Upload(ctx, "user1", nil, "rename-me.txt", strings.NewReader("second"))
if err != nil {
t.Fatalf("Upload source = %v", err)
}
_, err = svc.Update(ctx, "user1", file.ID, "existing.txt", nil)
assertAppError(t, err, model.KindConflict)
unchanged, err := svc.Get(ctx, "user1", file.ID)
if err != nil {
t.Fatalf("Get source after rejected rename = %v", err)
}
if unchanged.Name != "rename-me.txt" {
t.Fatalf("source name = %q, want rename-me.txt", unchanged.Name)
}
}
func TestFileService_UpdateReturnsNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -663,3 +714,42 @@ func TestFileService_SoftDeleteReturnsNotFoundForOtherUser(t *testing.T) {
err = svc.Delete(ctx, "user2", info.ID)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_UploadRechecksParentAfterStoragePromotion(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.File{}); err != nil {
t.Fatalf("migrate: %v", err)
}
store := newBlockingPromoteStorage()
svc := NewFileService(repository.NewFileRepository(db), store, 0, nil)
ctx := context.Background()
dir, err := svc.CreateDir(ctx, "user1", nil, "parent")
if err != nil {
t.Fatalf("CreateDir = %v", err)
}
uploadResult := make(chan error, 1)
go func() {
_, err := svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("content"))
uploadResult <- err
}()
<-store.promoted
if err := svc.Delete(ctx, "user1", dir.ID); err != nil {
t.Fatalf("Delete parent while upload is paused = %v", err)
}
close(store.release)
err = <-uploadResult
assertAppErrorMessage(t, err, model.KindNotFound, "parent directory not found")
store.mu.RLock()
remainingObjects := len(store.files)
store.mu.RUnlock()
if remainingObjects != 0 {
t.Fatalf("storage objects after rejected upload = %d, want 0", remainingObjects)
}
}