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
+18 -25
View File
@@ -17,9 +17,10 @@ import (
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/service"
"github.com/dhao2001/mygo/internal/testutil"
)
func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -63,7 +64,7 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
admin.DELETE("/users/:id", adminHandler.DeleteUser)
}
return r, userRepo
return r, userRepo, db
}
// registerHelper creates a user via the HTTP endpoint and returns the user ID.
@@ -113,24 +114,16 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
}
// makeAdminHelper promotes a user to admin in the database.
func makeAdminHelper(t *testing.T, userRepo repository.UserRepository, userID string) {
func makeAdminHelper(t *testing.T, db *gorm.DB, userID string) {
t.Helper()
user, err := userRepo.FindByID(context.Background(), userID)
if err != nil {
t.Fatalf("find user %s: %v", userID, err)
}
user.IsAdmin = true
if err := userRepo.Update(context.Background(), user); err != nil {
t.Fatalf("update user: %v", err)
}
testutil.SetUserAdmin(t, db, userID, true)
}
func TestAdminDeleteUser(t *testing.T) {
r, userRepo := setupAdminRouter(t)
r, _, db := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
makeAdminHelper(t, db, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
@@ -159,10 +152,10 @@ func TestAdminDeleteUser(t *testing.T) {
}
func TestAdminDeleteUserForbidden(t *testing.T) {
r, userRepo := setupAdminRouter(t)
r, _, db := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
makeAdminHelper(t, db, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
@@ -181,7 +174,7 @@ func TestAdminDeleteUserForbidden(t *testing.T) {
}
func TestAdminDeleteUserUnauthorized(t *testing.T) {
r, _ := setupAdminRouter(t)
r, _, _ := setupAdminRouter(t)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil)
rec := httptest.NewRecorder()
@@ -193,10 +186,10 @@ func TestAdminDeleteUserUnauthorized(t *testing.T) {
}
func TestAdminGetUser(t *testing.T) {
r, userRepo := setupAdminRouter(t)
r, _, db := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
makeAdminHelper(t, db, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
@@ -231,15 +224,15 @@ func TestAdminGetUser(t *testing.T) {
}
func TestAdminGetDeletedUser(t *testing.T) {
r, userRepo := setupAdminRouter(t)
r, userRepo, db := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
makeAdminHelper(t, db, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
// Soft-delete regular user directly via repo
if err := userRepo.Delete(context.Background(), userID); err != nil {
if err := userRepo.MarkAdminDeleted(context.Background(), userID); err != nil {
t.Fatalf("soft-delete user: %v", err)
}
@@ -267,17 +260,17 @@ func TestAdminGetDeletedUser(t *testing.T) {
}
func TestAdminListIncludesDeleted(t *testing.T) {
r, userRepo := setupAdminRouter(t)
r, userRepo, db := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
makeAdminHelper(t, db, adminID)
// Create 2 regular users
_ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123")
bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
// Soft-delete bob
if err := userRepo.Delete(context.Background(), bobID); err != nil {
if err := userRepo.MarkAdminDeleted(context.Background(), bobID); err != nil {
t.Fatalf("soft-delete user: %v", err)
}