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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user