diff --git a/internal/api/handlers/user/delete.go b/internal/api/handlers/user/delete.go new file mode 100644 index 0000000..ee507d1 --- /dev/null +++ b/internal/api/handlers/user/delete.go @@ -0,0 +1,150 @@ +package user + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + + "Coves/internal/api/middleware" + "Coves/internal/core/users" +) + +// DeleteHandler handles account deletion requests +type DeleteHandler struct { + userService users.UserService +} + +// NewDeleteHandler creates a new delete handler +func NewDeleteHandler(userService users.UserService) *DeleteHandler { + return &DeleteHandler{ + userService: userService, + } +} + +// DeleteAccountResponse represents the response for account deletion +type DeleteAccountResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// HandleDeleteAccount handles POST /xrpc/social.coves.actor.deleteAccount +// Deletes the authenticated user's account from the Coves AppView. +// This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. +// The user's identity remains intact for use with other atProto apps. +// +// Security: +// - Requires OAuth authentication +// - Users can ONLY delete their own account (DID from auth context) +// - No request body required - DID is derived from authenticated session +func (h *DeleteHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) { + // 1. Check HTTP method + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "Method not allowed") + return + } + + // 2. Extract authenticated user DID from request context (injected by auth middleware) + // SECURITY: This ensures users can ONLY delete their own account + userDID := middleware.GetUserDID(r) + if userDID == "" { + writeJSONError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // 3. Delete the account + // The service handles validation, logging, and atomic deletion + err := h.userService.DeleteAccount(r.Context(), userDID) + if err != nil { + handleServiceError(w, err, userDID) + return + } + + // 4. Return success response + // Marshal JSON before writing headers to catch encoding errors early + response := DeleteAccountResponse{ + Success: true, + Message: "Account deleted successfully. Your atProto identity remains intact on your PDS.", + } + + responseBytes, err := json.Marshal(response) + if err != nil { + slog.Error("failed to marshal delete account response", + slog.String("did", userDID), + slog.String("error", err.Error()), + ) + writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, writeErr := w.Write(responseBytes); writeErr != nil { + slog.Warn("failed to write delete account response", + slog.String("did", userDID), + slog.String("error", writeErr.Error()), + ) + } +} + +// writeJSONError writes a JSON error response +// Marshals JSON before writing headers to catch encoding errors +func writeJSONError(w http.ResponseWriter, statusCode int, errorType, message string) { + responseBytes, err := json.Marshal(map[string]interface{}{ + "error": errorType, + "message": message, + }) + if err != nil { + // Fallback to plain text if JSON encoding fails (should never happen with simple strings) + slog.Error("failed to marshal error response", slog.String("error", err.Error())) + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(message)) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if _, writeErr := w.Write(responseBytes); writeErr != nil { + slog.Warn("failed to write error response", slog.String("error", writeErr.Error())) + } +} + +// handleServiceError maps service errors to HTTP responses +func handleServiceError(w http.ResponseWriter, err error, userDID string) { + // Check for specific error types + switch { + case errors.Is(err, users.ErrUserNotFound): + writeJSONError(w, http.StatusNotFound, "AccountNotFound", "Account not found") + + case errors.Is(err, context.DeadlineExceeded): + slog.Error("account deletion timed out", + slog.String("did", userDID), + slog.String("error", err.Error()), + ) + writeJSONError(w, http.StatusGatewayTimeout, "Timeout", "Request timed out") + + case errors.Is(err, context.Canceled): + slog.Info("account deletion canceled", + slog.String("did", userDID), + slog.String("error", err.Error()), + ) + writeJSONError(w, http.StatusBadRequest, "RequestCanceled", "Request was canceled") + + default: + // Check for InvalidDIDError + var invalidDIDErr *users.InvalidDIDError + if errors.As(err, &invalidDIDErr) { + writeJSONError(w, http.StatusBadRequest, "InvalidDID", invalidDIDErr.Error()) + return + } + + // Internal server error - don't leak details + slog.Error("account deletion failed", + slog.String("did", userDID), + slog.String("error", err.Error()), + ) + writeJSONError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + } +} diff --git a/internal/api/handlers/user/delete_test.go b/internal/api/handlers/user/delete_test.go new file mode 100644 index 0000000..56de1ee --- /dev/null +++ b/internal/api/handlers/user/delete_test.go @@ -0,0 +1,358 @@ +package user + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "Coves/internal/api/middleware" + "Coves/internal/core/users" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockUserService is a mock implementation of users.UserService +type MockUserService struct { + mock.Mock +} + +func (m *MockUserService) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*users.User), args.Error(1) +} + +func (m *MockUserService) GetUserByDID(ctx context.Context, did string) (*users.User, error) { + args := m.Called(ctx, did) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*users.User), args.Error(1) +} + +func (m *MockUserService) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { + args := m.Called(ctx, handle) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*users.User), args.Error(1) +} + +func (m *MockUserService) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { + args := m.Called(ctx, did, newHandle) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*users.User), args.Error(1) +} + +func (m *MockUserService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { + args := m.Called(ctx, handle) + return args.String(0), args.Error(1) +} + +func (m *MockUserService) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*users.RegisterAccountResponse), args.Error(1) +} + +func (m *MockUserService) IndexUser(ctx context.Context, did, handle, pdsURL string) error { + args := m.Called(ctx, did, handle, pdsURL) + return args.Error(0) +} + +func (m *MockUserService) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { + args := m.Called(ctx, did) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*users.ProfileViewDetailed), args.Error(1) +} + +func (m *MockUserService) DeleteAccount(ctx context.Context, did string) error { + args := m.Called(ctx, did) + return args.Error(0) +} + +// TestDeleteAccountHandler_Success tests successful account deletion via XRPC +// Uses the actual production handler with middleware context injection +func TestDeleteAccountHandler_Success(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:testdelete123" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(nil) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + // Use middleware context injection instead of X-User-DID header + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"success":true`) + assert.Contains(t, w.Body.String(), "atProto identity remains intact") + + mockService.AssertExpectations(t) +} + +// TestDeleteAccountHandler_Unauthenticated tests deletion without authentication +func TestDeleteAccountHandler_Unauthenticated(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + // No context injection - simulates unauthenticated request + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "AuthRequired") + + mockService.AssertNotCalled(t, "DeleteAccount", mock.Anything, mock.Anything) +} + +// TestDeleteAccountHandler_UserNotFound tests deletion of non-existent user +func TestDeleteAccountHandler_UserNotFound(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:nonexistent" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(users.ErrUserNotFound) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "AccountNotFound") + + mockService.AssertExpectations(t) +} + +// TestDeleteAccountHandler_MethodNotAllowed tests that only POST is accepted +func TestDeleteAccountHandler_MethodNotAllowed(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), "did:plc:test") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) + }) + } + + mockService.AssertNotCalled(t, "DeleteAccount", mock.Anything, mock.Anything) +} + +// TestDeleteAccountHandler_InternalError tests handling of internal errors +func TestDeleteAccountHandler_InternalError(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:erroruser" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(assert.AnError) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "InternalServerError") + + mockService.AssertExpectations(t) +} + +// TestDeleteAccountHandler_ContextTimeout tests handling of context timeout +func TestDeleteAccountHandler_ContextTimeout(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:timeoutuser" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(context.DeadlineExceeded) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusGatewayTimeout, w.Code) + assert.Contains(t, w.Body.String(), "Timeout") + + mockService.AssertExpectations(t) +} + +// TestDeleteAccountHandler_ContextCanceled tests handling of context cancellation +func TestDeleteAccountHandler_ContextCanceled(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:canceluser" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(context.Canceled) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "RequestCanceled") + + mockService.AssertExpectations(t) +} + +// TestDeleteAccountHandler_InvalidDID tests handling of invalid DID format +func TestDeleteAccountHandler_InvalidDID(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:invaliddid" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(&users.InvalidDIDError{DID: "invalid", Reason: "must start with 'did:'"}) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "InvalidDID") + + mockService.AssertExpectations(t) +} + +// TestWebDeleteAccount_FormSubmission tests the web form-based deletion flow +func TestWebDeleteAccount_FormSubmission(t *testing.T) { + mockService := new(MockUserService) + + testDID := "did:plc:webdeleteuser" + mockService.On("DeleteAccount", mock.Anything, testDID).Return(nil) + + // Simulate form submission + form := url.Values{} + form.Add("confirm", "true") + + req := httptest.NewRequest(http.MethodPost, "/delete-account", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ctx := middleware.SetTestUserDID(req.Context(), testDID) + req = req.WithContext(ctx) + + // The web handler would parse the form and call DeleteAccount + // This test verifies the service layer is called correctly + err := mockService.DeleteAccount(ctx, testDID) + assert.NoError(t, err) + + mockService.AssertExpectations(t) +} + +// TestWebDeleteAccount_MissingConfirmation tests that confirmation is required +func TestWebDeleteAccount_MissingConfirmation(t *testing.T) { + form := url.Values{} + // NOT adding confirm=true + + req := httptest.NewRequest(http.MethodPost, "/delete-account", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + err := req.ParseForm() + assert.NoError(t, err) + assert.NotEqual(t, "true", req.FormValue("confirm")) +} + +// TestWebDeleteAccount_ConfirmationPresent tests confirmation checkbox validation +func TestWebDeleteAccount_ConfirmationPresent(t *testing.T) { + form := url.Values{} + form.Add("confirm", "true") + + req := httptest.NewRequest(http.MethodPost, "/delete-account", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + err := req.ParseForm() + assert.NoError(t, err) + assert.Equal(t, "true", req.FormValue("confirm")) +} + +// TestDeleteAccountHandler_DIDWithWhitespace tests DID handling with whitespace +// The service layer should handle trimming whitespace from DIDs +func TestDeleteAccountHandler_DIDWithWhitespace(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + // In reality, the middleware would provide a clean DID from the OAuth session. + // This test verifies the handler correctly passes the DID to the service. + trimmedDID := "did:plc:whitespaceuser" + mockService.On("DeleteAccount", mock.Anything, trimmedDID).Return(nil) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx := middleware.SetTestUserDID(req.Context(), trimmedDID) + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.HandleDeleteAccount(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockService.AssertExpectations(t) +} + +// TestDeleteAccountHandler_ConcurrentRequests tests handling of concurrent deletion attempts +// Verifies that repeated deletion attempts are handled gracefully +func TestDeleteAccountHandler_ConcurrentRequests(t *testing.T) { + mockService := new(MockUserService) + handler := NewDeleteHandler(mockService) + + testDID := "did:plc:concurrentuser" + + // First call succeeds, second call returns not found (already deleted) + mockService.On("DeleteAccount", mock.Anything, testDID).Return(nil).Once() + mockService.On("DeleteAccount", mock.Anything, testDID).Return(users.ErrUserNotFound).Once() + + // First request + req1 := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx1 := middleware.SetTestUserDID(req1.Context(), testDID) + req1 = req1.WithContext(ctx1) + + w1 := httptest.NewRecorder() + handler.HandleDeleteAccount(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + // Second request (simulating concurrent attempt that arrives after first completes) + req2 := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.deleteAccount", nil) + ctx2 := middleware.SetTestUserDID(req2.Context(), testDID) + req2 = req2.WithContext(ctx2) + + w2 := httptest.NewRecorder() + handler.HandleDeleteAccount(w2, req2) + assert.Equal(t, http.StatusNotFound, w2.Code) + + mockService.AssertExpectations(t) +} diff --git a/internal/api/routes/user.go b/internal/api/routes/user.go index c32bd03..5faf00f 100644 --- a/internal/api/routes/user.go +++ b/internal/api/routes/user.go @@ -1,6 +1,8 @@ package routes import ( + "Coves/internal/api/handlers/user" + "Coves/internal/api/middleware" "Coves/internal/core/users" "encoding/json" "errors" @@ -25,14 +27,20 @@ func NewUserHandler(userService users.UserService) *UserHandler { // RegisterUserRoutes registers user-related XRPC endpoints on the router // Implements social.coves.actor.* lexicon endpoints -func RegisterUserRoutes(r chi.Router, service users.UserService) { +func RegisterUserRoutes(r chi.Router, service users.UserService, authMiddleware *middleware.OAuthAuthMiddleware) { h := NewUserHandler(service) - // social.coves.actor.getprofile - query endpoint + // social.coves.actor.getprofile - query endpoint (public) r.Get("/xrpc/social.coves.actor.getprofile", h.GetProfile) - // social.coves.actor.signup - procedure endpoint + // social.coves.actor.signup - procedure endpoint (public) r.Post("/xrpc/social.coves.actor.signup", h.Signup) + + // social.coves.actor.deleteAccount - procedure endpoint (authenticated) + // Deletes the authenticated user's account from the Coves AppView. + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. + deleteHandler := user.NewDeleteHandler(service) + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.deleteAccount", deleteHandler.HandleDeleteAccount) } // GetProfile handles social.coves.actor.getprofile diff --git a/internal/core/users/errors.go b/internal/core/users/errors.go index 4f4edf3..18175e3 100644 --- a/internal/core/users/errors.go +++ b/internal/core/users/errors.go @@ -67,3 +67,16 @@ type PDSError struct { func (e *PDSError) Error() string { return fmt.Sprintf("PDS error (%d): %s", e.StatusCode, e.Message) } + +// InvalidDIDError is returned when a DID does not meet format requirements +type InvalidDIDError struct { + DID string + Reason string +} + +func (e *InvalidDIDError) Error() string { + if e.Reason != "" { + return fmt.Sprintf("invalid DID %q: %s", e.DID, e.Reason) + } + return fmt.Sprintf("invalid DID %q: must start with 'did:'", e.DID) +} diff --git a/internal/core/users/interfaces.go b/internal/core/users/interfaces.go index 08cc88f..1298b93 100644 --- a/internal/core/users/interfaces.go +++ b/internal/core/users/interfaces.go @@ -33,6 +33,27 @@ type UserRepository interface { // GetProfileStats retrieves aggregated statistics for a user profile. // Returns counts of posts, comments, subscriptions, memberships, and total reputation. GetProfileStats(ctx context.Context, did string) (*ProfileStats, error) + + // Delete removes a user and all associated data from the AppView database. + // This performs a cascading delete across all tables that reference the user's DID. + // The operation is atomic - either all data is deleted or none. + // + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. + // The user's identity remains intact for use with other atProto apps. + // + // Tables cleaned up (in order): + // 1. oauth_sessions (explicit DELETE) + // 2. oauth_requests (explicit DELETE) + // 3. community_subscriptions (explicit DELETE) + // 4. community_memberships (explicit DELETE) + // 5. community_blocks (explicit DELETE) + // 6. comments (explicit DELETE) + // 7. votes (explicit DELETE - FK removed in migration 014) + // 8. users (FK CASCADE deletes posts) + // + // Returns ErrUserNotFound if the user does not exist. + // Returns InvalidDIDError if the DID format is invalid. + Delete(ctx context.Context, did string) error } // UserService defines the interface for user business logic @@ -52,4 +73,21 @@ type UserService interface { // GetProfile retrieves a user's full profile with aggregated statistics. // Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon. GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) + + // DeleteAccount removes a user and all associated data from the Coves AppView. + // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. + // The user's identity remains intact for use with other atProto apps. + // + // Authorization: The caller must be the account owner. The XRPC handler extracts + // the authenticated user's DID from the OAuth session context and passes it here. + // This ensures users can ONLY delete their own accounts. + // + // This operation is required for Google Play compliance (account deletion requirement). + // + // The operation is atomic - either all data is deleted or none. + // Logs the deletion event for audit trail (DID, handle, timestamp). + // + // Returns ErrUserNotFound if the user does not exist. + // Returns InvalidDIDError if the DID format is invalid. + DeleteAccount(ctx context.Context, did string) error } diff --git a/internal/core/users/service.go b/internal/core/users/service.go index 3a55b04..b3d8038 100644 --- a/internal/core/users/service.go +++ b/internal/core/users/service.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + "log/slog" "net/http" "regexp" "strings" @@ -376,3 +377,50 @@ func validateHandle(handle string) error { return nil } + +// DeleteAccount removes a user and all associated data from the Coves AppView. +// This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. +// The user's identity remains intact for use with other atProto apps. +func (s *userService) DeleteAccount(ctx context.Context, did string) error { + did = strings.TrimSpace(did) + if did == "" { + return &InvalidDIDError{DID: did, Reason: "DID is required"} + } + + // Validate DID format + if !strings.HasPrefix(did, "did:") { + return &InvalidDIDError{DID: did, Reason: "must start with 'did:'"} + } + + // Get user handle for audit log (before deletion) + // We fetch the user first to include handle in the audit log + user, err := s.userRepo.GetByDID(ctx, did) + if err != nil { + // If user not found, return that error + if errors.Is(err, ErrUserNotFound) { + return ErrUserNotFound + } + return fmt.Errorf("failed to get user for deletion: %w", err) + } + + // Perform the deletion + if err := s.userRepo.Delete(ctx, did); err != nil { + // Log failed deletion attempt + slog.Error("account deletion failed", + slog.String("did", did), + slog.String("handle", user.Handle), + slog.String("error", err.Error()), + ) + return fmt.Errorf("failed to delete account: %w", err) + } + + // Log successful deletion for audit trail + // SECURITY: Only log DID and handle (non-sensitive identifiers), never tokens + slog.Info("account deleted successfully", + slog.String("did", did), + slog.String("handle", user.Handle), + slog.Time("deleted_at", time.Now().UTC()), + ) + + return nil +} diff --git a/internal/core/users/service_test.go b/internal/core/users/service_test.go new file mode 100644 index 0000000..0dc8b67 --- /dev/null +++ b/internal/core/users/service_test.go @@ -0,0 +1,467 @@ +package users + +import ( + "context" + "errors" + "testing" + "time" + + "Coves/internal/atproto/identity" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockUserRepository is a mock implementation of UserRepository +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) Create(ctx context.Context, user *User) (*User, error) { + args := m.Called(ctx, user) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +func (m *MockUserRepository) GetByDID(ctx context.Context, did string) (*User, error) { + args := m.Called(ctx, did) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +func (m *MockUserRepository) GetByHandle(ctx context.Context, handle string) (*User, error) { + args := m.Called(ctx, handle) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +func (m *MockUserRepository) UpdateHandle(ctx context.Context, did, newHandle string) (*User, error) { + args := m.Called(ctx, did, newHandle) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +func (m *MockUserRepository) GetByDIDs(ctx context.Context, dids []string) (map[string]*User, error) { + args := m.Called(ctx, dids) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]*User), args.Error(1) +} + +func (m *MockUserRepository) GetProfileStats(ctx context.Context, did string) (*ProfileStats, error) { + args := m.Called(ctx, did) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ProfileStats), args.Error(1) +} + +func (m *MockUserRepository) Delete(ctx context.Context, did string) error { + args := m.Called(ctx, did) + return args.Error(0) +} + +// MockIdentityResolver is a mock implementation of identity.Resolver +type MockIdentityResolver struct { + mock.Mock +} + +func (m *MockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { + args := m.Called(ctx, identifier) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*identity.Identity), args.Error(1) +} + +func (m *MockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) { + args := m.Called(ctx, handle) + return args.String(0), args.String(1), args.Error(2) +} + +func (m *MockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { + args := m.Called(ctx, did) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*identity.DIDDocument), args.Error(1) +} + +func (m *MockIdentityResolver) Purge(ctx context.Context, identifier string) error { + args := m.Called(ctx, identifier) + return args.Error(0) +} + +// TestDeleteAccount_Success tests successful account deletion +func TestDeleteAccount_Success(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:testuser123" + testHandle := "testuser.test" + testUser := &User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + + // Setup expectations + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) + mockRepo.On("Delete", mock.Anything, testDID).Return(nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, testDID) + assert.NoError(t, err) + + mockRepo.AssertExpectations(t) +} + +// TestDeleteAccount_UserNotFound tests deletion of non-existent user +func TestDeleteAccount_UserNotFound(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:nonexistent" + + // Setup expectations + mockRepo.On("GetByDID", mock.Anything, testDID).Return(nil, ErrUserNotFound) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, testDID) + assert.ErrorIs(t, err, ErrUserNotFound) + + mockRepo.AssertExpectations(t) + mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything) +} + +// TestDeleteAccount_EmptyDID tests deletion with empty DID +func TestDeleteAccount_EmptyDID(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, "") + assert.Error(t, err) + + // Verify it's an InvalidDIDError + var invalidDIDErr *InvalidDIDError + assert.True(t, errors.As(err, &invalidDIDErr), "expected InvalidDIDError") + assert.Contains(t, err.Error(), "DID is required") + + mockRepo.AssertNotCalled(t, "GetByDID", mock.Anything, mock.Anything) + mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything) +} + +// TestDeleteAccount_WhitespaceDID tests deletion with whitespace-only DID +func TestDeleteAccount_WhitespaceDID(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, " ") + assert.Error(t, err) + + // Verify it's an InvalidDIDError + var invalidDIDErr *InvalidDIDError + assert.True(t, errors.As(err, &invalidDIDErr), "expected InvalidDIDError") + assert.Contains(t, err.Error(), "DID is required") +} + +// TestDeleteAccount_LeadingTrailingWhitespace tests that DIDs are trimmed +func TestDeleteAccount_LeadingTrailingWhitespace(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + // The input has whitespace but after trimming should be a valid DID + inputDID := " did:plc:whitespacetest " + trimmedDID := "did:plc:whitespacetest" + + testUser := &User{ + DID: trimmedDID, + Handle: "whitespacetest.test", + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + + // Expectations should use the trimmed DID + mockRepo.On("GetByDID", mock.Anything, trimmedDID).Return(testUser, nil) + mockRepo.On("Delete", mock.Anything, trimmedDID).Return(nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, inputDID) + assert.NoError(t, err) + + mockRepo.AssertExpectations(t) +} + +// TestDeleteAccount_InvalidDIDFormat tests deletion with invalid DID format +func TestDeleteAccount_InvalidDIDFormat(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, "invalid-did-format") + assert.Error(t, err) + + // Verify it's an InvalidDIDError + var invalidDIDErr *InvalidDIDError + assert.True(t, errors.As(err, &invalidDIDErr), "expected InvalidDIDError") + assert.Contains(t, err.Error(), "must start with 'did:'") +} + +// TestDeleteAccount_RepoDeleteFails tests handling when repository delete fails +func TestDeleteAccount_RepoDeleteFails(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:testuser456" + testUser := &User{ + DID: testDID, + Handle: "testuser456.test", + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + + // Setup expectations + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) + mockRepo.On("Delete", mock.Anything, testDID).Return(errors.New("database error")) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, testDID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete account") + + mockRepo.AssertExpectations(t) +} + +// TestDeleteAccount_GetByDIDFails tests handling when GetByDID fails (non-NotFound error) +func TestDeleteAccount_GetByDIDFails(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:testuser789" + + // Setup expectations + mockRepo.On("GetByDID", mock.Anything, testDID).Return(nil, errors.New("database connection error")) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, testDID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get user for deletion") + + mockRepo.AssertExpectations(t) + mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything) +} + +// TestDeleteAccount_ContextCancellation tests behavior with cancelled context +func TestDeleteAccount_ContextCancellation(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:testcontextcancel" + + // Create a cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Setup expectations - GetByDID should fail due to cancelled context + mockRepo.On("GetByDID", mock.Anything, testDID).Return(nil, context.Canceled) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + + err := service.DeleteAccount(ctx, testDID) + assert.Error(t, err) + + mockRepo.AssertExpectations(t) +} + +// TestDeleteAccount_PLCAndWebDID tests deletion works with both did:plc and did:web +func TestDeleteAccount_PLCAndWebDID(t *testing.T) { + tests := []struct { + name string + did string + }{ + { + name: "did:plc format", + did: "did:plc:abc123xyz", + }, + { + name: "did:web format", + did: "did:web:example.com", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testUser := &User{ + DID: tc.did, + Handle: "testuser.test", + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + + mockRepo.On("GetByDID", mock.Anything, tc.did).Return(testUser, nil) + mockRepo.On("Delete", mock.Anything, tc.did).Return(nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.DeleteAccount(ctx, tc.did) + assert.NoError(t, err) + + mockRepo.AssertExpectations(t) + }) + } +} + +// TestGetUserByDID tests retrieving a user by DID +func TestGetUserByDID(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:testuser" + testUser := &User{ + DID: testDID, + Handle: "testuser.test", + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + user, err := service.GetUserByDID(ctx, testDID) + require.NoError(t, err) + assert.Equal(t, testDID, user.DID) + assert.Equal(t, "testuser.test", user.Handle) +} + +// TestGetUserByDID_EmptyDID tests GetUserByDID with empty DID +func TestGetUserByDID_EmptyDID(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + _, err := service.GetUserByDID(ctx, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "DID is required") +} + +// TestGetUserByHandle tests retrieving a user by handle +func TestGetUserByHandle(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testHandle := "testuser.test" + testUser := &User{ + DID: "did:plc:testuser", + Handle: testHandle, + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + + mockRepo.On("GetByHandle", mock.Anything, testHandle).Return(testUser, nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + user, err := service.GetUserByHandle(ctx, testHandle) + require.NoError(t, err) + assert.Equal(t, testHandle, user.Handle) +} + +// TestGetProfile tests retrieving a user's profile with stats +func TestGetProfile(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:profileuser" + testUser := &User{ + DID: testDID, + Handle: "profileuser.test", + PDSURL: "https://test.pds", + CreatedAt: time.Now(), + } + testStats := &ProfileStats{ + PostCount: 10, + CommentCount: 25, + CommunityCount: 5, + MembershipCount: 3, + Reputation: 150, + } + + mockRepo.On("GetByDID", mock.Anything, testDID).Return(testUser, nil) + mockRepo.On("GetProfileStats", mock.Anything, testDID).Return(testStats, nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + profile, err := service.GetProfile(ctx, testDID) + require.NoError(t, err) + assert.Equal(t, testDID, profile.DID) + assert.Equal(t, 10, profile.Stats.PostCount) + assert.Equal(t, 150, profile.Stats.Reputation) +} + +// TestIndexUser tests indexing a new user +func TestIndexUser(t *testing.T) { + mockRepo := new(MockUserRepository) + mockResolver := new(MockIdentityResolver) + + testDID := "did:plc:newuser" + testHandle := "newuser.test" + testPDSURL := "https://test.pds" + + testUser := &User{ + DID: testDID, + Handle: testHandle, + PDSURL: testPDSURL, + CreatedAt: time.Now(), + } + + mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *User) bool { + return u.DID == testDID && u.Handle == testHandle + })).Return(testUser, nil) + + service := NewUserService(mockRepo, mockResolver, "https://default.pds") + ctx := context.Background() + + err := service.IndexUser(ctx, testDID, testHandle, testPDSURL) + assert.NoError(t, err) + + mockRepo.AssertExpectations(t) +} diff --git a/internal/db/postgres/user_repo.go b/internal/db/postgres/user_repo.go index dd9d8a8..baad65d 100644 --- a/internal/db/postgres/user_repo.go +++ b/internal/db/postgres/user_repo.go @@ -5,7 +5,7 @@ import ( "context" "database/sql" "fmt" - "log" + "log/slog" "strings" "github.com/lib/pq" @@ -140,7 +140,7 @@ func (r *postgresUserRepo) GetByDIDs(ctx context.Context, dids []string) (map[st } defer func() { if closeErr := rows.Close(); closeErr != nil { - log.Printf("Warning: Failed to close rows: %v", closeErr) + slog.Warn("failed to close rows", slog.String("error", closeErr.Error())) } }() @@ -197,3 +197,89 @@ func (r *postgresUserRepo) GetProfileStats(ctx context.Context, did string) (*us return stats, nil } + +// Delete removes a user and all associated data from the AppView database. +// This performs a cascading delete across all tables that reference the user's DID. +// The operation is atomic - either all data is deleted or none. +// +// This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. +func (r *postgresUserRepo) Delete(ctx context.Context, did string) error { + // Validate DID format + if !strings.HasPrefix(did, "did:") { + return &users.InvalidDIDError{DID: did, Reason: "must start with 'did:'"} + } + + // Start transaction for atomic deletion + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to start transaction for did=%s: %w", did, err) + } + defer func() { + if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { + slog.Error("failed to rollback transaction", + slog.String("did", did), + slog.String("error", err.Error()), + ) + } + }() + + // Delete in correct order to avoid foreign key violations + // Tables without FK constraints on user_did are deleted first + + // 1. Delete OAuth sessions (explicit DELETE) + if _, err := tx.ExecContext(ctx, `DELETE FROM oauth_sessions WHERE did = $1`, did); err != nil { + return fmt.Errorf("failed to delete oauth_sessions for did=%s: %w", did, err) + } + + // 2. Delete OAuth requests (explicit DELETE) + if _, err := tx.ExecContext(ctx, `DELETE FROM oauth_requests WHERE did = $1`, did); err != nil { + return fmt.Errorf("failed to delete oauth_requests for did=%s: %w", did, err) + } + + // 3. Delete community subscriptions (explicit DELETE) + if _, err := tx.ExecContext(ctx, `DELETE FROM community_subscriptions WHERE user_did = $1`, did); err != nil { + return fmt.Errorf("failed to delete community_subscriptions for did=%s: %w", did, err) + } + + // 4. Delete community memberships (explicit DELETE) + if _, err := tx.ExecContext(ctx, `DELETE FROM community_memberships WHERE user_did = $1`, did); err != nil { + return fmt.Errorf("failed to delete community_memberships for did=%s: %w", did, err) + } + + // 5. Delete community blocks (explicit DELETE) + if _, err := tx.ExecContext(ctx, `DELETE FROM community_blocks WHERE user_did = $1`, did); err != nil { + return fmt.Errorf("failed to delete community_blocks for did=%s: %w", did, err) + } + + // 6. Delete comments (explicit DELETE) + if _, err := tx.ExecContext(ctx, `DELETE FROM comments WHERE commenter_did = $1`, did); err != nil { + return fmt.Errorf("failed to delete comments for did=%s: %w", did, err) + } + + // 7. Delete votes (explicit DELETE - FK constraint removed in migration 014) + if _, err := tx.ExecContext(ctx, `DELETE FROM votes WHERE voter_did = $1`, did); err != nil { + return fmt.Errorf("failed to delete votes for did=%s: %w", did, err) + } + + // 8. Delete user (FK CASCADE deletes posts) + result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE did = $1`, did) + if err != nil { + return fmt.Errorf("failed to delete user did=%s: %w", did, err) + } + + // Check if user was actually deleted + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected for did=%s: %w", did, err) + } + if rowsAffected == 0 { + return users.ErrUserNotFound + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction for did=%s: %w", did, err) + } + + return nil +} diff --git a/internal/db/postgres/user_repo_test.go b/internal/db/postgres/user_repo_test.go new file mode 100644 index 0000000..7fe82b8 --- /dev/null +++ b/internal/db/postgres/user_repo_test.go @@ -0,0 +1,703 @@ +package postgres + +import ( + "Coves/internal/core/users" + "context" + "database/sql" + "fmt" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupUserTestDB creates a test database connection and runs migrations +func setupUserTestDB(t *testing.T) *sql.DB { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" + } + + db, err := sql.Open("postgres", dsn) + require.NoError(t, err, "Failed to connect to test database") + + // Run migrations + require.NoError(t, goose.Up(db, "../../db/migrations"), "Failed to run migrations") + + return db +} + +// cleanupUserData removes all test data related to users +func cleanupUserData(t *testing.T, db *sql.DB, did string) { + // Clean up in reverse order of foreign key dependencies + _, err := db.Exec("DELETE FROM votes WHERE voter_did = $1", did) + require.NoError(t, err) + + _, err = db.Exec("DELETE FROM comments WHERE commenter_did = $1", did) + require.NoError(t, err) + + _, err = db.Exec("DELETE FROM community_blocks WHERE user_did = $1", did) + require.NoError(t, err) + + _, err = db.Exec("DELETE FROM community_memberships WHERE user_did = $1", did) + require.NoError(t, err) + + _, err = db.Exec("DELETE FROM community_subscriptions WHERE user_did = $1", did) + require.NoError(t, err) + + _, err = db.Exec("DELETE FROM oauth_requests WHERE did = $1", did) + require.NoError(t, err) + + _, err = db.Exec("DELETE FROM oauth_sessions WHERE did = $1", did) + require.NoError(t, err) + + // Posts are deleted by CASCADE when user is deleted + _, err = db.Exec("DELETE FROM users WHERE did = $1", did) + require.NoError(t, err) +} + +// createTestCommunity creates a minimal test community for foreign key constraints +func createTestCommunity(t *testing.T, db *sql.DB, did, handle, ownerDID string) { + query := ` + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) + VALUES ($1, $2, $3, $4, $4, $4, NOW()) + ON CONFLICT (did) DO NOTHING + ` + _, err := db.Exec(query, did, handle, "Test Community", ownerDID) + require.NoError(t, err, "Failed to create test community") +} + +func TestUserRepo_Delete_Success(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testdeleteuser123" + testHandle := "testdeleteuser123.test" + communityDID := "did:plc:testdeletecommunity" + + defer cleanupUserData(t, db, testDID) + defer func() { + // Cleanup community + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) + }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Create test community (needed for subscriptions/memberships) + createTestCommunity(t, db, communityDID, "c.testdeletecommunity", testDID) + + // Add related data to verify cascade deletion + + // 1. OAuth session + _, err = db.Exec(` + INSERT INTO oauth_sessions (did, handle, pds_url, access_token, refresh_token, dpop_private_jwk, auth_server_iss, expires_at, session_id) + VALUES ($1, $2, $3, 'test_access', 'test_refresh', '{}', 'https://auth.test', NOW() + INTERVAL '1 day', 'test_session_id') + `, testDID, testHandle, "https://test.pds") + require.NoError(t, err) + + // 2. Community subscription + _, err = db.Exec(` + INSERT INTO community_subscriptions (user_did, community_did, record_uri, record_cid) + VALUES ($1, $2, 'at://test/sub', 'bafytest') + `, testDID, communityDID) + require.NoError(t, err) + + // 3. Community membership + _, err = db.Exec(` + INSERT INTO community_memberships (user_did, community_did) + VALUES ($1, $2) + `, testDID, communityDID) + require.NoError(t, err) + + // 4. Comment (no FK constraint) + _, err = db.Exec(` + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafycomment', 'rkey123', $2, 'at://test/post', 'bafyroot', 'at://test/post', 'bafyparent', 'Test comment', NOW()) + `, "at://"+testDID+"/social.coves.community.comment/test123", testDID) + require.NoError(t, err) + + // 5. Vote (no FK constraint) + _, err = db.Exec(` + INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) + VALUES ($1, 'bafyvote', 'rkey456', $2, 'at://test/post', 'bafysubject', 'up', NOW()) + `, "at://"+testDID+"/social.coves.feed.vote/test456", testDID) + require.NoError(t, err) + + // Verify user exists before deletion + _, err = repo.GetByDID(ctx, testDID) + require.NoError(t, err) + + // Delete the user + err = repo.Delete(ctx, testDID) + assert.NoError(t, err) + + // Verify user is deleted + _, err = repo.GetByDID(ctx, testDID) + assert.ErrorIs(t, err, users.ErrUserNotFound) + + // Verify related data is cleaned up + var count int + + // OAuth sessions should be deleted + err = db.QueryRow("SELECT COUNT(*) FROM oauth_sessions WHERE did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "OAuth sessions should be deleted") + + // Community subscriptions should be deleted + err = db.QueryRow("SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "Community subscriptions should be deleted") + + // Community memberships should be deleted + err = db.QueryRow("SELECT COUNT(*) FROM community_memberships WHERE user_did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "Community memberships should be deleted") + + // Comments should be deleted + err = db.QueryRow("SELECT COUNT(*) FROM comments WHERE commenter_did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "Comments should be deleted") + + // Votes should be deleted (note: the delete happens through transaction, not FK) + err = db.QueryRow("SELECT COUNT(*) FROM votes WHERE voter_did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "Votes should be deleted") +} + +func TestUserRepo_Delete_NonExistentUser(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Try to delete a user that doesn't exist + err := repo.Delete(ctx, "did:plc:nonexistentuser999") + assert.ErrorIs(t, err, users.ErrUserNotFound) +} + +func TestUserRepo_Delete_InvalidDID(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Try to delete with invalid DID format + err := repo.Delete(ctx, "invalid-did-format") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid DID format") +} + +func TestUserRepo_Delete_Idempotent(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testdeletetwice" + testHandle := "testdeletetwice.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Delete the user first time + err = repo.Delete(ctx, testDID) + assert.NoError(t, err) + + // Delete again - should return ErrUserNotFound (not crash) + err = repo.Delete(ctx, testDID) + assert.ErrorIs(t, err, users.ErrUserNotFound) +} + +func TestUserRepo_Delete_WithPosts_CascadeDeletes(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testdeletewithposts" + testHandle := "testdeletewithposts.test" + communityDID := "did:plc:testpostcommunity" + + defer cleanupUserData(t, db, testDID) + defer func() { + // Cleanup posts and community + _, _ = db.Exec("DELETE FROM posts WHERE author_did = $1", testDID) + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) + }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Create test community (needed for post FK) + createTestCommunity(t, db, communityDID, "c.testpostcommunity", testDID) + + // Create post (has FK constraint with CASCADE delete) + _, err = db.Exec(` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafypost', 'postkey', $2, $3, 'Test Post', NOW()) + `, "at://"+communityDID+"/social.coves.community.post/testpost", testDID, communityDID) + require.NoError(t, err) + + // Verify post exists + var postCount int + err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE author_did = $1", testDID).Scan(&postCount) + require.NoError(t, err) + assert.Equal(t, 1, postCount) + + // Delete the user + err = repo.Delete(ctx, testDID) + assert.NoError(t, err) + + // Verify user is deleted + _, err = repo.GetByDID(ctx, testDID) + assert.ErrorIs(t, err, users.ErrUserNotFound) + + // Verify posts are cascade deleted (FK ON DELETE CASCADE) + err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE author_did = $1", testDID).Scan(&postCount) + require.NoError(t, err) + assert.Equal(t, 0, postCount, "Posts should be cascade deleted with user") +} + +func TestUserRepo_Delete_TransactionRollback(t *testing.T) { + // This test verifies that if any part of the deletion fails, + // the entire transaction is rolled back and no partial deletions occur. + // We can't easily simulate a failure in the middle of the transaction, + // but we verify that the function properly handles the transaction. + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testtransaction" + testHandle := "testtransaction.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Create a cancelled context to simulate a failure + cancelledCtx, cancel := context.WithCancel(ctx) + cancel() // Cancel immediately + + // Try to delete with cancelled context + err = repo.Delete(cancelledCtx, testDID) + assert.Error(t, err, "Should fail with cancelled context") + + // Verify user still exists (transaction was rolled back) + _, err = repo.GetByDID(ctx, testDID) + assert.NoError(t, err, "User should still exist after failed deletion") +} + +func TestUserRepo_Create(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testcreateuser" + testHandle := "testcreateuser.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + + created, err := repo.Create(ctx, user) + assert.NoError(t, err) + assert.Equal(t, testDID, created.DID) + assert.Equal(t, testHandle, created.Handle) + assert.NotZero(t, created.CreatedAt) +} + +func TestUserRepo_Create_DuplicateDID(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testduplicatedid" + testHandle := "testduplicatedid.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + + // Create first time + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Try to create again with same DID + user2 := &users.User{ + DID: testDID, + Handle: "different.handle.test", + PDSURL: "https://test.pds", + } + + _, err = repo.Create(ctx, user2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user with DID already exists") +} + +func TestUserRepo_GetByDID(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testgetbydid" + testHandle := "testgetbydid.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create user first + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Get by DID + retrieved, err := repo.GetByDID(ctx, testDID) + assert.NoError(t, err) + assert.Equal(t, testDID, retrieved.DID) + assert.Equal(t, testHandle, retrieved.Handle) +} + +func TestUserRepo_GetByDID_NotFound(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + repo := NewUserRepository(db) + ctx := context.Background() + + _, err := repo.GetByDID(ctx, "did:plc:nonexistent") + assert.ErrorIs(t, err, users.ErrUserNotFound) +} + +func TestUserRepo_GetByHandle(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testgetbyhandle" + testHandle := "testgetbyhandle.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create user first + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Get by handle + retrieved, err := repo.GetByHandle(ctx, testHandle) + assert.NoError(t, err) + assert.Equal(t, testDID, retrieved.DID) + assert.Equal(t, testHandle, retrieved.Handle) +} + +func TestUserRepo_UpdateHandle(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testupdatehandle" + oldHandle := "testupdatehandle.test" + newHandle := "newhandle.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create user first + user := &users.User{ + DID: testDID, + Handle: oldHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Update handle + updated, err := repo.UpdateHandle(ctx, testDID, newHandle) + assert.NoError(t, err) + assert.Equal(t, newHandle, updated.Handle) + + // Verify by fetching again + retrieved, err := repo.GetByDID(ctx, testDID) + assert.NoError(t, err) + assert.Equal(t, newHandle, retrieved.Handle) +} + +func TestUserRepo_GetProfileStats(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testprofilestats" + testHandle := "testprofilestats.test" + communityDID := "did:plc:teststatscommunity" + + defer cleanupUserData(t, db, testDID) + defer func() { + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) + }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create user first + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Create test community + createTestCommunity(t, db, communityDID, "c.teststatscommunity", testDID) + + // Add subscription + _, err = db.Exec(` + INSERT INTO community_subscriptions (user_did, community_did, record_uri, record_cid) + VALUES ($1, $2, 'at://test/sub', 'bafytest') + `, testDID, communityDID) + require.NoError(t, err) + + // Add membership + _, err = db.Exec(` + INSERT INTO community_memberships (user_did, community_did, reputation_score) + VALUES ($1, $2, 100) + `, testDID, communityDID) + require.NoError(t, err) + + // Add post + _, err = db.Exec(` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafystatpost', 'statpostkey', $2, $3, 'Stats Test Post', NOW()) + `, "at://"+communityDID+"/social.coves.community.post/statspost", testDID, communityDID) + require.NoError(t, err) + + // Add comment + _, err = db.Exec(` + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafystatcomment', 'statcommentkey', $2, 'at://test/post', 'bafyroot', 'at://test/post', 'bafyparent', 'Stats Test Comment', NOW()) + `, "at://"+testDID+"/social.coves.community.comment/statscomment", testDID) + require.NoError(t, err) + + // Get profile stats + stats, err := repo.GetProfileStats(ctx, testDID) + assert.NoError(t, err) + assert.Equal(t, 1, stats.PostCount) + assert.Equal(t, 1, stats.CommentCount) + assert.Equal(t, 1, stats.CommunityCount) + assert.Equal(t, 1, stats.MembershipCount) + assert.Equal(t, 100, stats.Reputation) +} + +func TestUserRepo_Delete_WithOAuthRequests(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testoauthrequests" + testHandle := "testoauthrequests.test" + + defer cleanupUserData(t, db, testDID) + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Add OAuth request (pending authorization) + _, err = db.Exec(` + INSERT INTO oauth_requests (state, did, handle, pds_url, pkce_verifier, dpop_private_jwk, auth_server_iss) + VALUES ($1, $2, $3, $4, 'verifier', '{}', 'https://auth.test') + `, "test_state_"+testDID, testDID, testHandle, "https://test.pds") + require.NoError(t, err) + + // Delete the user + err = repo.Delete(ctx, testDID) + assert.NoError(t, err) + + // Verify OAuth requests are deleted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM oauth_requests WHERE did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "OAuth requests should be deleted") +} + +func TestUserRepo_Delete_WithCommunityBlocks(t *testing.T) { + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testcommunityblocks" + testHandle := "testcommunityblocks.test" + communityDID := "did:plc:testblockcommunity" + + defer cleanupUserData(t, db, testDID) + defer func() { + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) + }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Create test community + createTestCommunity(t, db, communityDID, "c.testblockcommunity", testDID) + + // Add community block + _, err = db.Exec(` + INSERT INTO community_blocks (user_did, community_did, record_uri, record_cid) + VALUES ($1, $2, 'at://test/block', 'bafyblock') + `, testDID, communityDID) + require.NoError(t, err) + + // Delete the user + err = repo.Delete(ctx, testDID) + assert.NoError(t, err) + + // Verify community blocks are deleted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM community_blocks WHERE user_did = $1", testDID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 0, count, "Community blocks should be deleted") +} + +func TestUserRepo_Delete_TimingPerformance(t *testing.T) { + // This test ensures deletion completes in a reasonable time + // even with multiple related records + db := setupUserTestDB(t) + defer func() { _ = db.Close() }() + + testDID := "did:plc:testperformance" + testHandle := "testperformance.test" + communityDID := "did:plc:testperfcommunity" + + // Clean up any leftover data from previous test runs + cleanupUserData(t, db, testDID) + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) + + defer cleanupUserData(t, db, testDID) + defer func() { + _, _ = db.Exec("DELETE FROM communities WHERE did = $1", communityDID) + }() + + repo := NewUserRepository(db) + ctx := context.Background() + + // Create test user + user := &users.User{ + DID: testDID, + Handle: testHandle, + PDSURL: "https://test.pds", + } + _, err := repo.Create(ctx, user) + require.NoError(t, err) + + // Create test community + createTestCommunity(t, db, communityDID, "c.testperfcommunity", testDID) + + // Add multiple comments + for i := 0; i < 10; i++ { + _, err = db.Exec(` + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, $2, $3, $4, 'at://test/post', 'bafyroot', 'at://test/post', 'bafyparent', 'Test comment', NOW()) + `, "at://"+testDID+"/social.coves.community.comment/perf"+string(rune('0'+i)), "bafyperf"+string(rune('0'+i)), "perfkey"+string(rune('0'+i)), testDID) + require.NoError(t, err) + } + + // Add multiple votes (each must have unique subject_uri due to unique_voter_subject_active constraint) + for i := 0; i < 10; i++ { + subjectURI := fmt.Sprintf("at://test/post/perf%d", i) + _, err = db.Exec(` + INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) + VALUES ($1, $2, $3, $4, $5, 'bafysubject', 'up', NOW()) + `, "at://"+testDID+"/social.coves.feed.vote/perf"+string(rune('0'+i)), "bafyvoteperf"+string(rune('0'+i)), "voteperfkey"+string(rune('0'+i)), testDID, subjectURI) + require.NoError(t, err) + } + + // Time the deletion + start := time.Now() + err = repo.Delete(ctx, testDID) + elapsed := time.Since(start) + + assert.NoError(t, err) + assert.Less(t, elapsed, 5*time.Second, "Deletion should complete in under 5 seconds") + + t.Logf("Deletion of user with %d comments and %d votes took %v", 10, 10, elapsed) +} diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 0cefa38..0abb11c 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -217,9 +217,10 @@ func TestGetProfileEndpoint(t *testing.T) { t.Fatalf("Failed to create test user: %v", err) } - // Set up HTTP router + // Set up HTTP router with auth middleware r := chi.NewRouter() - routes.RegisterUserRoutes(r, userService) + authMiddleware, _ := CreateTestOAuthMiddleware("did:plc:testuser") + routes.RegisterUserRoutes(r, userService, authMiddleware) // Test 1: Get profile by DID t.Run("Get Profile By DID", func(t *testing.T) { @@ -847,7 +848,8 @@ func TestGetProfile_NonExistentDID(t *testing.T) { t.Run("HTTP endpoint returns 404 for non-existent DID", func(t *testing.T) { r := chi.NewRouter() - routes.RegisterUserRoutes(r, userService) + authMiddleware, _ := CreateTestOAuthMiddleware("did:plc:testuser") + routes.RegisterUserRoutes(r, userService, authMiddleware) req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=did:plc:nonexistentuser12345", nil) w := httptest.NewRecorder() @@ -894,9 +896,10 @@ func TestProfileStatsEndpoint(t *testing.T) { t.Fatalf("Failed to create test user: %v", err) } - // Set up HTTP router + // Set up HTTP router with auth middleware r := chi.NewRouter() - routes.RegisterUserRoutes(r, userService) + authMiddleware, _ := CreateTestOAuthMiddleware("did:plc:testuser") + routes.RegisterUserRoutes(r, userService, authMiddleware) t.Run("Response includes stats object", func(t *testing.T) { req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor="+testDID, nil) @@ -1087,3 +1090,239 @@ func TestHandleValidation(t *testing.T) { }) } } + +// TestAccountDeletion_Integration tests the complete account deletion flow +// from handler → service → repository with a real database +func TestAccountDeletion_Integration(t *testing.T) { + db := setupTestDB(t) + defer func() { + if err := db.Close(); err != nil { + t.Logf("Failed to close database: %v", err) + } + }() + + uniqueSuffix := time.Now().UnixNano() + testDID := fmt.Sprintf("did:plc:deletetest%d", uniqueSuffix) + testHandle := fmt.Sprintf("deletetest%d.test", uniqueSuffix) + + // Wire up dependencies + userRepo := postgres.NewUserRepository(db) + resolver := identity.NewResolver(db, identity.DefaultConfig()) + userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") + + ctx := context.Background() + + // Create test user + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ + DID: testDID, + Handle: testHandle, + PDSURL: "http://localhost:3001", + }) + if err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + + // Create test community for FK relationships + testCommunityDID := fmt.Sprintf("did:plc:deletetestcommunity%d", uniqueSuffix) + _, err = db.Exec(` + INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) + VALUES ($1, $2, 'Delete Test Community', 'did:plc:owner1', 'did:plc:owner1', 'did:plc:owner1', NOW()) + `, testCommunityDID, fmt.Sprintf("deletetestcommunity%d.test", uniqueSuffix)) + if err != nil { + t.Fatalf("Failed to insert test community: %v", err) + } + + // Create related data across all tables + t.Run("Setup test data", func(t *testing.T) { + // Posts + for i := 1; i <= 3; i++ { + _, err = db.Exec(` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, content, created_at, indexed_at) + VALUES ($1, $2, $3, $4, $5, 'Test Post', 'Content', NOW(), NOW()) + `, fmt.Sprintf("at://%s/social.coves.post/delete%d", testDID, i), + fmt.Sprintf("deletecid%d", i), + fmt.Sprintf("delete%d", i), + testDID, + testCommunityDID) + if err != nil { + t.Fatalf("Failed to insert post %d: %v", i, err) + } + } + + // Community subscription + _, err = db.Exec(` + INSERT INTO community_subscriptions (user_did, community_did, subscribed_at) + VALUES ($1, $2, NOW()) + `, testDID, testCommunityDID) + if err != nil { + t.Fatalf("Failed to insert subscription: %v", err) + } + + // Community membership + _, err = db.Exec(` + INSERT INTO community_memberships (user_did, community_did, reputation_score, contribution_count, is_banned, is_moderator, joined_at, last_active_at) + VALUES ($1, $2, 100, 5, false, false, NOW(), NOW()) + `, testDID, testCommunityDID) + if err != nil { + t.Fatalf("Failed to insert membership: %v", err) + } + + // Vote (using one of the posts) + postURI := fmt.Sprintf("at://%s/social.coves.post/delete1", testDID) + _, err = db.Exec(` + INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) + VALUES ($1, 'votecid', 'vote1', $2, $3, 'postcid', 'up', NOW()) + `, fmt.Sprintf("at://%s/social.coves.vote/delete1", testDID), testDID, postURI) + if err != nil { + t.Fatalf("Failed to insert vote: %v", err) + } + + // Comments + postCID := "deletecid1" + for i := 1; i <= 2; i++ { + _, err = db.Exec(` + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $5, $6, 'Test comment', NOW(), NOW()) + `, fmt.Sprintf("at://%s/social.coves.comment/delete%d", testDID, i), + fmt.Sprintf("deletecommentcid%d", i), + fmt.Sprintf("deletecomment%d", i), + testDID, + postURI, + postCID) + if err != nil { + t.Fatalf("Failed to insert comment %d: %v", i, err) + } + } + }) + + // Verify data exists before deletion + t.Run("Verify data exists before deletion", func(t *testing.T) { + var count int + + // Check user exists + err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE did = $1`, testDID).Scan(&count) + if err != nil || count != 1 { + t.Fatalf("Expected 1 user, got %d (err: %v)", count, err) + } + + // Check posts exist + err = db.QueryRow(`SELECT COUNT(*) FROM posts WHERE author_did = $1`, testDID).Scan(&count) + if err != nil || count != 3 { + t.Fatalf("Expected 3 posts, got %d (err: %v)", count, err) + } + + // Check subscription exists + err = db.QueryRow(`SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1`, testDID).Scan(&count) + if err != nil || count != 1 { + t.Fatalf("Expected 1 subscription, got %d (err: %v)", count, err) + } + + // Check membership exists + err = db.QueryRow(`SELECT COUNT(*) FROM community_memberships WHERE user_did = $1`, testDID).Scan(&count) + if err != nil || count != 1 { + t.Fatalf("Expected 1 membership, got %d (err: %v)", count, err) + } + + // Check vote exists + err = db.QueryRow(`SELECT COUNT(*) FROM votes WHERE voter_did = $1`, testDID).Scan(&count) + if err != nil || count != 1 { + t.Fatalf("Expected 1 vote, got %d (err: %v)", count, err) + } + + // Check comments exist + err = db.QueryRow(`SELECT COUNT(*) FROM comments WHERE commenter_did = $1`, testDID).Scan(&count) + if err != nil || count != 2 { + t.Fatalf("Expected 2 comments, got %d (err: %v)", count, err) + } + }) + + // Delete account + t.Run("Delete account via service", func(t *testing.T) { + err := userService.DeleteAccount(ctx, testDID) + if err != nil { + t.Fatalf("Failed to delete account: %v", err) + } + }) + + // Verify all data is deleted + t.Run("Verify all data deleted after deletion", func(t *testing.T) { + var count int + + // Check user deleted + err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE did = $1`, testDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking users: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 users after deletion, got %d", count) + } + + // Check posts deleted (via FK CASCADE) + err = db.QueryRow(`SELECT COUNT(*) FROM posts WHERE author_did = $1`, testDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking posts: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 posts after deletion, got %d", count) + } + + // Check subscription deleted + err = db.QueryRow(`SELECT COUNT(*) FROM community_subscriptions WHERE user_did = $1`, testDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking subscriptions: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 subscriptions after deletion, got %d", count) + } + + // Check membership deleted + err = db.QueryRow(`SELECT COUNT(*) FROM community_memberships WHERE user_did = $1`, testDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking memberships: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 memberships after deletion, got %d", count) + } + + // Check vote deleted + err = db.QueryRow(`SELECT COUNT(*) FROM votes WHERE voter_did = $1`, testDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking votes: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 votes after deletion, got %d", count) + } + + // Check comments deleted + err = db.QueryRow(`SELECT COUNT(*) FROM comments WHERE commenter_did = $1`, testDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking comments: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 comments after deletion, got %d", count) + } + }) + + // Verify second delete returns ErrUserNotFound + t.Run("Delete non-existent account returns error", func(t *testing.T) { + err := userService.DeleteAccount(ctx, testDID) + if err == nil { + t.Error("Expected error when deleting already-deleted account") + } + if err != users.ErrUserNotFound { + t.Errorf("Expected ErrUserNotFound, got: %v", err) + } + }) + + // Verify community still exists (only user data deleted) + t.Run("Community still exists after user deletion", func(t *testing.T) { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM communities WHERE did = $1`, testCommunityDID).Scan(&count) + if err != nil { + t.Fatalf("Error checking community: %v", err) + } + if count != 1 { + t.Errorf("Expected community to still exist, got count %d", count) + } + }) +}