package test import ( "RenewCMS/api/handlers/article" "RenewCMS/api/middleware" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" domainArticle "RenewCMS/internal/domain/article" domainImage "RenewCMS/internal/domain/image" domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" "gorm.io/gorm" ) func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository { t.Helper() db := openInMemoryDb(t) if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil { t.Fatalf("failed to migrate: %v", err) } repo := persistence.NewArticleRepository(db) if _, err := repo.Create(domainArticle.Article{Title: "Backyard Renovation Update", Body: "Body"}); err != nil { t.Fatalf("failed to seed article: %v", err) } return repo } func assertWriteResultIsArticleNotFound(t *testing.T, err error) { t.Helper() if !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } } func assertWriteResultIsImageNotFound(t *testing.T, err error) { t.Helper() if !errors.Is(err, domainImage.ErrImageNotFound) { t.Errorf("expected image.ErrImageNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } } func assertWriteResultIsUserNotFound(t *testing.T, err error) { t.Helper() if !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } } func TestTheArticleRepositoryReportsAMissingArticleAsADomainErrorOnAWrite(t *testing.T) { t.Run("UpdateBody and body New body", func(t *testing.T) { repo := newSeededArticleRepository(t) _, err := repo.UpdateBody(9, "New body") assertWriteResultIsArticleNotFound(t, err) }) t.Run("UpdateIsOnline and is_online true", func(t *testing.T) { repo := newSeededArticleRepository(t) _, err := repo.UpdateIsOnline(9, true) assertWriteResultIsArticleNotFound(t, err) }) t.Run("Delete", func(t *testing.T) { repo := newSeededArticleRepository(t) err := repo.Delete(9) assertWriteResultIsArticleNotFound(t, err) }) } func newSeededUserRepository(t *testing.T) *persistence.UserRepository { t.Helper() db := openInMemoryDb(t) if err := db.AutoMigrate(&models.User{}); err != nil { t.Fatalf("failed to migrate: %v", err) } repo := persistence.NewUserRepository(db) if _, err := repo.Create(domainUser.User{Username: "alice", Password: "hunter2example", Email: "alice@example.com", VerificationCode: "code"}); err != nil { t.Fatalf("failed to seed user: %v", err) } return repo } func TestTheUserRepositoryReportsAMissingUserAsADomainErrorOnAWrite(t *testing.T) { t.Run("UpdateVerificationStatus and is_verified true", func(t *testing.T) { repo := newSeededUserRepository(t) _, err := repo.UpdateVerificationStatus(9, true) assertWriteResultIsUserNotFound(t, err) }) t.Run("UpdatePassword and password newHashedPassword", func(t *testing.T) { repo := newSeededUserRepository(t) _, err := repo.UpdatePassword(9, "newHashedPassword") assertWriteResultIsUserNotFound(t, err) }) t.Run("UpdatePasswordResetCode and code newHashedResetCode", func(t *testing.T) { repo := newSeededUserRepository(t) _, err := repo.UpdatePasswordResetCode(9, "newHashedResetCode", time.Now().Add(time.Hour)) assertWriteResultIsUserNotFound(t, err) }) t.Run("ResetPassword and hashedPassword newHashedPassword", func(t *testing.T) { repo := newSeededUserRepository(t) _, err := repo.ResetPassword(9, "newHashedPassword") assertWriteResultIsUserNotFound(t, err) }) t.Run("Delete", func(t *testing.T) { repo := newSeededUserRepository(t) err := repo.Delete(9) assertWriteResultIsUserNotFound(t, err) }) } func TestTheUserRepositoryResetPasswordAdvancesTokenVersionOnASuccessfulWrite(t *testing.T) { repo := newSeededUserRepository(t) seeded, err := repo.GetByUsername("alice") if err != nil { t.Fatalf("failed to load seeded user: %v", err) } updated, err := repo.ResetPassword(seeded.ID, "newHashedPassword") if err != nil { t.Fatalf("expected no error, got %v", err) } if updated.TokenVersion != seeded.TokenVersion+1 { t.Errorf("expected TokenVersion %d, got %d", seeded.TokenVersion+1, updated.TokenVersion) } if updated.Password != "newHashedPassword" { t.Errorf("expected password to be updated, got %q", updated.Password) } if updated.PasswordResetCode != "" { t.Errorf("expected PasswordResetCode to be cleared, got %q", updated.PasswordResetCode) } if !updated.PasswordResetExpiration.IsZero() { t.Errorf("expected PasswordResetExpiration to be zeroed, got %v", updated.PasswordResetExpiration) } reloaded, err := repo.Get(seeded.ID) if err != nil { t.Fatalf("failed to reload user: %v", err) } if reloaded.TokenVersion != seeded.TokenVersion+1 { t.Errorf("expected persisted TokenVersion %d, got %d", seeded.TokenVersion+1, reloaded.TokenVersion) } } func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing.T) { db := openInMemoryDb(t) if err := db.AutoMigrate(&models.Image{}); err != nil { t.Fatalf("failed to migrate: %v", err) } if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil { t.Fatalf("failed to seed image: %v", err) } repo := persistence.NewImageRepository(db) err := repo.Delete(9) assertWriteResultIsImageNotFound(t, err) } func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T) { articleRepo := newMockArticleRepo() articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} if _, err := articleRepo.UpdateBody(9, "New body"); !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } if _, err := articleRepo.UpdateIsOnline(9, true); !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } if err := articleRepo.Delete(9); !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } userRepo := newMockUserRepo() userRepo.users[1] = domainUser.User{ID: 1, Username: "alice"} if _, err := userRepo.UpdateVerificationStatus(9, true); !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if _, err := userRepo.UpdatePassword(9, "newHashedPassword"); !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if _, err := userRepo.UpdatePasswordResetCode(9, "newHashedResetCode", time.Now().Add(time.Hour)); !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if err := userRepo.Delete(9); !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } } func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { userRepo := newVerifiedUserRepo() getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(jwtauth.Verifier(token)) r.Use(jwtauth.Authenticator(token)) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Route("/article", func(r chi.Router) { r.Delete("/{id}", articleHandler.DeleteArticle) }) }) }) return r } func TestWriteNotFoundErrorsHttp(t *testing.T) { validator.InitValidator() token := jwtauth.New("HS256", []byte("test-secret"), nil) t.Run("Deleting an unknown article answers 404, not a silent success or a 500", func(t *testing.T) { articleRepo := newMockArticleRepo() router := newDeleteArticleRouter(articleRepo, token) req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999", nil) setJwtCookie(t, req, token, 1) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected status 404, got %d", rec.Code) } if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) t.Run("Deleting an existing article still answers 204", func(t *testing.T) { articleRepo := newMockArticleRepo() articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} router := newDeleteArticleRouter(articleRepo, token) req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil) setJwtCookie(t, req, token, 1) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("expected status 204, got %d", rec.Code) } if _, exists := articleRepo.articles[1]; exists { t.Errorf("expected article 1 to have been deleted") } }) t.Run("The delete-article route still requires authentication before existence is even checked", func(t *testing.T) { articleRepo := newMockArticleRepo() router := newDeleteArticleRouter(articleRepo, token) req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected status 401, got %d", rec.Code) } }) }