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
+36 -28
View File
@@ -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)
}
}