tercul-backend/internal/data/sql/work_repository.go
google-labs-jules[bot] c2e9a118e2 feat(testing): Increase test coverage and fix authz bugs
This commit significantly increases the test coverage across the application and fixes several underlying bugs that were discovered while writing the new tests.

The key changes include:

- **New Tests:** Added extensive integration and unit tests for GraphQL resolvers, application services, and data repositories, substantially increasing the test coverage for packages like `graphql`, `user`, `translation`, and `analytics`.

- **Authorization Bug Fixes:**
  - Fixed a critical bug where a user creating a `Work` was not correctly associated as its author, causing subsequent permission failures.
  - Corrected the authorization logic in `authz.Service` to properly check for entity ownership by non-admin users.

- **Test Refactoring:**
  - Refactored numerous test suites to use `testify/mock` instead of manual mocks, improving test clarity and maintainability.
  - Isolated integration tests by creating a fresh admin user and token for each test run, eliminating test pollution.
  - Centralized domain errors into `internal/domain/errors.go` and updated repositories to use them, making error handling more consistent.

- **Code Quality Improvements:**
  - Replaced manual mock implementations with `testify/mock` for better consistency.
  - Cleaned up redundant and outdated test files.

These changes stabilize the test suite, improve the overall quality of the codebase, and move the project closer to the goal of 80% test coverage.
2025-10-09 07:03:45 +00:00

261 lines
7.3 KiB
Go

package sql
import (
"context"
"errors"
"fmt"
"tercul/internal/domain"
"tercul/internal/platform/config"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
)
type workRepository struct {
*BaseRepositoryImpl[domain.Work]
db *gorm.DB
tracer trace.Tracer
}
// NewWorkRepository creates a new WorkRepository.
func NewWorkRepository(db *gorm.DB, cfg *config.Config) domain.WorkRepository {
return &workRepository{
BaseRepositoryImpl: NewBaseRepositoryImpl[domain.Work](db, cfg),
db: db,
tracer: otel.Tracer("work.repository"),
}
}
// FindByTitle finds works by title (partial match)
func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "FindByTitle")
defer span.End()
var works []domain.Work
if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+title+"%").Find(&works).Error; err != nil {
return nil, err
}
return works, nil
}
// FindByAuthor finds works by author ID
func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "FindByAuthor")
defer span.End()
var works []domain.Work
if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.work_id = works.id").
Where("work_authors.author_id = ?", authorID).
Find(&works).Error; err != nil {
return nil, err
}
return works, nil
}
// FindByCategory finds works by category ID
func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "FindByCategory")
defer span.End()
var works []domain.Work
if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.work_id = works.id").
Where("work_categories.category_id = ?", categoryID).
Find(&works).Error; err != nil {
return nil, err
}
return works, nil
}
// FindByLanguage finds works by language with pagination
func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
ctx, span := r.tracer.Start(ctx, "FindByLanguage")
defer span.End()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var works []domain.Work
var totalCount int64
// Get total count
if err := r.db.WithContext(ctx).Model(&domain.Work{}).Where("language = ?", language).Count(&totalCount).Error; err != nil {
return nil, err
}
// Calculate offset
offset := (page - 1) * pageSize
// Get paginated data
if err := r.db.WithContext(ctx).Where("language = ?", language).
Offset(offset).Limit(pageSize).
Find(&works).Error; err != nil {
return nil, err
}
// Calculate total pages
totalPages := int(totalCount) / pageSize
if int(totalCount)%pageSize > 0 {
totalPages++
}
hasNext := page < totalPages
hasPrev := page > 1
return &domain.PaginatedResult[domain.Work]{
Items: works,
TotalCount: totalCount,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
HasNext: hasNext,
HasPrev: hasPrev,
}, nil
}
// ListByCollectionID finds works by collection ID
func (r *workRepository) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "ListByCollectionID")
defer span.End()
var works []domain.Work
if err := r.db.WithContext(ctx).Joins("JOIN collection_works ON collection_works.work_id = works.id").
Where("collection_works.collection_id = ?", collectionID).
Find(&works).Error; err != nil {
return nil, err
}
return works, nil
}
// Delete removes a work and its associations
func (r *workRepository) Delete(ctx context.Context, id uint) error {
ctx, span := r.tracer.Start(ctx, "Delete")
defer span.End()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Manually delete associations
if err := tx.Select("Copyrights", "Monetizations", "Authors", "Tags", "Categories").Delete(&domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}).Error; err != nil {
return err
}
// Also delete the work itself
if err := tx.Delete(&domain.Work{}, id).Error; err != nil {
return err
}
return nil
})
}
// GetWithTranslations gets a work with its translations
func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetWithTranslations")
defer span.End()
return r.FindWithPreload(ctx, []string{"Translations"}, id)
}
// GetWithAssociations gets a work with all of its direct and many-to-many associations.
func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetWithAssociations")
defer span.End()
associations := []string{
"Translations",
"Authors",
"Tags",
"Categories",
"Copyrights",
"Monetizations",
}
return r.FindWithPreload(ctx, associations, id)
}
// GetWithAssociationsInTx gets a work with all associations within a transaction.
func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*domain.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetWithAssociationsInTx")
defer span.End()
var entity domain.Work
query := tx.WithContext(ctx)
associations := []string{
"Translations",
"Authors",
"Tags",
"Categories",
"Copyrights",
"Monetizations",
}
for _, preload := range associations {
query = query.Preload(preload)
}
if err := query.First(&entity, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrEntityNotFound
}
return nil, fmt.Errorf("database operation failed: %w", err)
}
return &entity, nil
}
// IsAuthor checks if a user is an author of a work.
// Note: This assumes a direct relationship between user ID and author ID,
// which may need to be revised based on the actual domain model.
func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
ctx, span := r.tracer.Start(ctx, "IsAuthor")
defer span.End()
var count int64
err := r.db.WithContext(ctx).
Table("work_authors").
Where("work_id = ? AND author_id = ?", workID, authorID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// ListWithTranslations lists works with their translations
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
ctx, span := r.tracer.Start(ctx, "ListWithTranslations")
defer span.End()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var works []domain.Work
var totalCount int64
// Get total count
if err := r.db.WithContext(ctx).Model(&domain.Work{}).Count(&totalCount).Error; err != nil {
return nil, err
}
// Calculate offset
offset := (page - 1) * pageSize
// Get paginated data with preloaded translations
if err := r.db.WithContext(ctx).Preload("Translations").
Offset(offset).Limit(pageSize).
Find(&works).Error; err != nil {
return nil, err
}
// Calculate total pages
totalPages := int(totalCount) / pageSize
if int(totalCount)%pageSize > 0 {
totalPages++
}
hasNext := page < totalPages
hasPrev := page > 1
return &domain.PaginatedResult[domain.Work]{
Items: works,
TotalCount: totalCount,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
HasNext: hasNext,
HasPrev: hasPrev,
}, nil
}