chore: remove legacy .keep placeholders from unused ops/adapters/pkg dirs

This commit is contained in:
Damir Mukimov 2025-12-26 13:28:30 +01:00
parent ad749d9184
commit 6fdf0a97fd
No known key found for this signature in database
GPG Key ID: 42996CC7C73BC750
30 changed files with 1776 additions and 235 deletions

View File

@ -29,6 +29,7 @@ import (
"tercul/internal/app/translation"
"tercul/internal/app/user"
"tercul/internal/app/work"
datacache "tercul/internal/data/cache"
dbsql "tercul/internal/data/sql"
"tercul/internal/jobs/linguistics"
"tercul/internal/observability"
@ -187,6 +188,14 @@ func main() {
if cacheErr != nil {
app_log.Warn("Redis cache initialization failed, APQ disabled: " + cacheErr.Error())
} else {
// Optional repository caching (opt-in)
if os.Getenv("REPO_CACHE_ENABLED") == "true" {
repos.Work = datacache.NewCachedWorkRepository(repos.Work, redisCache, nil)
repos.Author = datacache.NewCachedAuthorRepository(repos.Author, redisCache, nil)
repos.Translation = datacache.NewCachedTranslationRepository(repos.Translation, redisCache, nil)
app_log.Info("Repository caching enabled")
}
queryCache = &cache.GraphQLCacheAdapter{RedisCache: redisCache}
app_log.Info("Redis cache initialized for APQ")
}

View File

@ -1 +1 @@
{"last_processed_id":3,"total_processed":3,"last_updated":"2025-11-30T21:59:16.811419372Z"}
{"last_processed_id":3,"total_processed":3,"last_updated":"2025-12-26T12:33:05.005477+01:00"}

View File

@ -1,43 +0,0 @@
{
"contentTypeSlug": "blog",
"title": "The Future of Artificial Intelligence",
"slug": "future-of-ai",
"status": "published",
"content": {
"excerpt": "A deep dive into the future of artificial intelligence, exploring its potential impact on society, industry, and our daily lives.",
"content": "<p>Artificial intelligence (AI) is no longer a concept confined to science fiction. It's a powerful force that's reshaping our world in countless ways. From the algorithms that power our social media feeds to the sophisticated systems that drive autonomous vehicles, AI is already here. But what does the future hold for this transformative technology?</p><p>In this post, we'll explore some of the most exciting advancements on the horizon, including the rise of general AI, the potential for AI-driven scientific discovery, and the ethical considerations that we must address as we move forward.</p>",
"publishDate": "2024-09-15",
"author": "Dr. Evelyn Reed",
"tags": ["AI", "Machine Learning", "Technology"],
"meta_title": "The Future of AI: A Comprehensive Overview",
"meta_description": "Learn about the future of artificial intelligence and its potential impact on our world."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-1",
"translation_group_id": "tg-future-of-ai",
"lifecycle": {
"state": "published",
"published_at": "2024-09-15T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/future-of-ai",
"og_title": "The Future of Artificial Intelligence",
"og_description": "A deep dive into the future of AI.",
"twitter_card": "summary_large_image"
},
"taxonomy": {
"categories": ["Technology", "Science"],
"featured": true
},
"relations": {
"related_posts": ["post-2"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/ai-future.jpg",
"alt": "An abstract image representing artificial intelligence."
}
}
}

View File

@ -1,43 +0,0 @@
{
"contentTypeSlug": "blog",
"title": "A Guide to Sustainable Living",
"slug": "guide-to-sustainable-living",
"status": "published",
"content": {
"excerpt": "Discover practical tips and simple changes you can make to live a more sustainable and eco-friendly lifestyle.",
"content": "<p>Living sustainably doesn't have to be complicated. It's about making conscious choices that reduce your environmental impact. In this guide, we'll cover everything from reducing your plastic consumption to creating a more energy-efficient home.</p><p>We'll also explore the benefits of a plant-based diet and how you can support local, sustainable businesses in your community.</p>",
"publishDate": "2024-09-18",
"author": "Liam Carter",
"tags": ["Sustainability", "Eco-Friendly", "Lifestyle"],
"meta_title": "Your Ultimate Guide to Sustainable Living",
"meta_description": "Learn how to live a more sustainable lifestyle with our comprehensive guide."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-2",
"translation_group_id": "tg-sustainable-living",
"lifecycle": {
"state": "published",
"published_at": "2024-09-18T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/guide-to-sustainable-living",
"og_title": "A Guide to Sustainable Living",
"og_description": "Discover practical tips for a more sustainable lifestyle.",
"twitter_card": "summary"
},
"taxonomy": {
"categories": ["Lifestyle", "Environment"],
"featured": false
},
"relations": {
"related_posts": ["post-1", "post-3"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/sustainable-living.jpg",
"alt": "A person holding a reusable water bottle in a lush green environment."
}
}
}

View File

@ -1,43 +0,0 @@
{
"contentTypeSlug": "blog",
"title": "The Art of Mindful Meditation",
"slug": "art-of-mindful-meditation",
"status": "published",
"content": {
"excerpt": "Learn the basics of mindful meditation and how it can help you reduce stress, improve focus, and cultivate a sense of inner peace.",
"content": "<p>In our fast-paced world, it's easy to get caught up in the chaos. Mindful meditation offers a powerful tool to ground yourself in the present moment and find a sense of calm amidst the noise.</p><p>This post will guide you through the fundamental principles of mindfulness and provide simple exercises to help you start your meditation practice.</p>",
"publishDate": "2024-09-22",
"author": "Isabella Rossi",
"tags": ["Mindfulness", "Meditation", "Wellness"],
"meta_title": "A Beginner's Guide to Mindful Meditation",
"meta_description": "Start your journey with mindful meditation and discover its many benefits."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-3",
"translation_group_id": "tg-mindful-meditation",
"lifecycle": {
"state": "published",
"published_at": "2024-09-22T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/art-of-mindful-meditation",
"og_title": "The Art of Mindful Meditation",
"og_description": "Learn the basics of mindful meditation.",
"twitter_card": "summary_large_image"
},
"taxonomy": {
"categories": ["Wellness", "Lifestyle"],
"featured": true
},
"relations": {
"related_posts": ["post-2", "post-4"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/meditation.jpg",
"alt": "A person meditating peacefully in a serene setting."
}
}
}

View File

@ -1,43 +0,0 @@
{
"contentTypeSlug": "blog",
"title": "Exploring the Wonders of the Cosmos",
"slug": "exploring-the-cosmos",
"status": "published",
"content": {
"excerpt": "Join us on a journey through the cosmos as we explore distant galaxies, mysterious black holes, and the search for extraterrestrial life.",
"content": "<p>The universe is a vast and mysterious place, filled with wonders that we are only just beginning to understand. From the birth of stars to the formation of galaxies, the cosmos is a story of epic proportions.</p><p>In this post, we'll take a look at some of the most awe-inspiring discoveries in modern astronomy and consider the big questions that continue to drive our exploration of space.</p>",
"publishDate": "2024-09-25",
"author": "Dr. Kenji Tanaka",
"tags": ["Astronomy", "Space", "Science"],
"meta_title": "A Journey Through the Cosmos",
"meta_description": "Explore the wonders of the universe with our guide to modern astronomy."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-4",
"translation_group_id": "tg-exploring-the-cosmos",
"lifecycle": {
"state": "published",
"published_at": "2024-09-25T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/exploring-the-cosmos",
"og_title": "Exploring the Wonders of the Cosmos",
"og_description": "A journey through the cosmos.",
"twitter_card": "summary"
},
"taxonomy": {
"categories": ["Science", "Astronomy"],
"featured": false
},
"relations": {
"related_posts": ["post-1", "post-5"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/cosmos.jpg",
"alt": "A stunning image of a spiral galaxy."
}
}
}

View File

@ -1,43 +0,0 @@
{
"contentTypeSlug": "blog",
"title": "The Rise of Remote Work",
"slug": "rise-of-remote-work",
"status": "published",
"content": {
"excerpt": "Remote work is here to stay. In this post, we'll explore the benefits and challenges of working from home and how to create a productive and healthy remote work environment.",
"content": "<p>The way we work has been fundamentally transformed in recent years. Remote work has gone from a niche perk to a mainstream reality for millions of people around the world.</p><p>This shift has brought with it a host of new opportunities and challenges. We'll discuss how to stay focused and motivated while working from home, how to maintain a healthy work-life balance, and how companies can build strong remote teams.</p>",
"publishDate": "2024-09-28",
"author": "Chloe Davis",
"tags": ["Remote Work", "Productivity", "Future of Work"],
"meta_title": "Navigating the World of Remote Work",
"meta_description": "Learn how to thrive in a remote work environment."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-5",
"translation_group_id": "tg-remote-work",
"lifecycle": {
"state": "published",
"published_at": "2024-09-28T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/rise-of-remote-work",
"og_title": "The Rise of Remote Work",
"og_description": "The benefits and challenges of working from home.",
"twitter_card": "summary_large_image"
},
"taxonomy": {
"categories": ["Work", "Productivity"],
"featured": true
},
"relations": {
"related_posts": ["post-2", "post-4"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/remote-work.jpg",
"alt": "A person working on a laptop in a comfortable home office setting."
}
}
}

View File

@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"tercul/internal/adapters/graphql/model"
"tercul/internal/app/auth"
"tercul/internal/app/author"
@ -27,6 +28,23 @@ import (
"time"
)
func toModelUserRole(role domain.UserRole) model.UserRole {
switch strings.ToLower(string(role)) {
case "reader":
return model.UserRoleReader
case "contributor":
return model.UserRoleContributor
case "reviewer":
return model.UserRoleReviewer
case "editor":
return model.UserRoleEditor
case "admin":
return model.UserRoleAdmin
default:
return model.UserRoleReader
}
}
// Register is the resolver for the register field.
func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInput) (*model.AuthPayload, error) {
// Convert GraphQL input to service input
@ -54,7 +72,7 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp
FirstName: &authResponse.User.FirstName,
LastName: &authResponse.User.LastName,
DisplayName: &authResponse.User.DisplayName,
Role: model.UserRole(authResponse.User.Role),
Role: toModelUserRole(authResponse.User.Role),
Verified: authResponse.User.Verified,
Active: authResponse.User.Active,
},
@ -85,7 +103,7 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*
FirstName: &authResponse.User.FirstName,
LastName: &authResponse.User.LastName,
DisplayName: &authResponse.User.DisplayName,
Role: model.UserRole(authResponse.User.Role),
Role: toModelUserRole(authResponse.User.Role),
Verified: authResponse.User.Verified,
Active: authResponse.User.Active,
},
@ -483,7 +501,7 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input mode
DisplayName: &updatedUser.DisplayName,
Bio: &updatedUser.Bio,
AvatarURL: &updatedUser.AvatarURL,
Role: model.UserRole(updatedUser.Role),
Role: toModelUserRole(updatedUser.Role),
Verified: updatedUser.Verified,
Active: updatedUser.Active,
}, nil
@ -1129,7 +1147,7 @@ func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload
FirstName: &authResponse.User.FirstName,
LastName: &authResponse.User.LastName,
DisplayName: &authResponse.User.DisplayName,
Role: model.UserRole(authResponse.User.Role),
Role: toModelUserRole(authResponse.User.Role),
Verified: authResponse.User.Verified,
Active: authResponse.User.Active,
},
@ -1231,7 +1249,7 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input model.UserIn
DisplayName: &updatedUser.DisplayName,
Bio: &updatedUser.Bio,
AvatarURL: &updatedUser.AvatarURL,
Role: model.UserRole(updatedUser.Role),
Role: toModelUserRole(updatedUser.Role),
Verified: updatedUser.Verified,
Active: updatedUser.Active,
}, nil
@ -1525,7 +1543,7 @@ func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error
DisplayName: &userRecord.DisplayName,
Bio: &userRecord.Bio,
AvatarURL: &userRecord.AvatarURL,
Role: model.UserRole(userRecord.Role),
Role: toModelUserRole(userRecord.Role),
Verified: userRecord.Verified,
Active: userRecord.Active,
}, nil
@ -1550,7 +1568,7 @@ func (r *queryResolver) UserByEmail(ctx context.Context, email string) (*model.U
DisplayName: &userRecord.DisplayName,
Bio: &userRecord.Bio,
AvatarURL: &userRecord.AvatarURL,
Role: model.UserRole(userRecord.Role),
Role: toModelUserRole(userRecord.Role),
Verified: userRecord.Verified,
Active: userRecord.Active,
}, nil
@ -1575,7 +1593,7 @@ func (r *queryResolver) UserByUsername(ctx context.Context, username string) (*m
DisplayName: &userRecord.DisplayName,
Bio: &userRecord.Bio,
AvatarURL: &userRecord.AvatarURL,
Role: model.UserRole(userRecord.Role),
Role: toModelUserRole(userRecord.Role),
Verified: userRecord.Verified,
Active: userRecord.Active,
}, nil
@ -1664,7 +1682,7 @@ func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
DisplayName: &userRecord.DisplayName,
Bio: &userRecord.Bio,
AvatarURL: &userRecord.AvatarURL,
Role: model.UserRole(userRecord.Role),
Role: toModelUserRole(userRecord.Role),
Verified: userRecord.Verified,
Active: userRecord.Active,
}, nil
@ -1705,7 +1723,7 @@ func (r *queryResolver) UserProfile(ctx context.Context, userID string) (*model.
DisplayName: &user.DisplayName,
Bio: &user.Bio,
AvatarURL: &user.AvatarURL,
Role: model.UserRole(user.Role),
Role: toModelUserRole(user.Role),
Verified: user.Verified,
Active: user.Active,
},
@ -2003,7 +2021,7 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32,
}
params := domainsearch.SearchParams{
Query: query,
Query: query,
Filters: domainsearch.SearchFilters{
Languages: searchFilters.Languages,
Tags: searchFilters.Tags,

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

View File

@ -0,0 +1,210 @@
package cache
import (
"context"
"time"
"tercul/internal/domain"
platform_cache "tercul/internal/platform/cache"
"gorm.io/gorm"
)
type CachedAuthorRepository struct {
inner domain.AuthorRepository
opt Options
}
func NewCachedAuthorRepository(inner domain.AuthorRepository, c platform_cache.Cache, opt *Options) *CachedAuthorRepository {
resolved := DefaultOptions(c)
if opt != nil {
resolved = *opt
if resolved.Cache == nil {
resolved.Cache = c
}
if resolved.Keys == nil {
resolved.Keys = platform_cache.NewDefaultKeyGenerator("tercul:repo:")
}
if resolved.EntityTTL == 0 {
resolved.EntityTTL = 1 * time.Hour
}
if resolved.ListTTL == 0 {
resolved.ListTTL = 5 * time.Minute
}
}
return &CachedAuthorRepository{inner: inner, opt: resolved}
}
func (r *CachedAuthorRepository) Create(ctx context.Context, entity *domain.Author) error {
err := r.inner.Create(ctx, entity)
if err == nil {
invalidateEntityType(ctx, r.opt.Cache, "author")
}
return err
}
func (r *CachedAuthorRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Author) error {
err := r.inner.CreateInTx(ctx, tx, entity)
if err == nil {
invalidateEntityType(ctx, r.opt.Cache, "author")
}
return err
}
func (r *CachedAuthorRepository) GetByID(ctx context.Context, id uint) (*domain.Author, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.Author
key := r.opt.Keys.EntityKey("author", id)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
author, err := r.inner.GetByID(ctx, id)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && author != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.EntityKey("author", id), author, r.opt.EntityTTL)
}
return author, nil
}
func (r *CachedAuthorRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) {
return r.inner.GetByIDWithOptions(ctx, id, options)
}
func (r *CachedAuthorRepository) Update(ctx context.Context, entity *domain.Author) error {
err := r.inner.Update(ctx, entity)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("author", entity.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "author")
}
return err
}
func (r *CachedAuthorRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Author) error {
err := r.inner.UpdateInTx(ctx, tx, entity)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("author", entity.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "author")
}
return err
}
func (r *CachedAuthorRepository) Delete(ctx context.Context, id uint) error {
err := r.inner.Delete(ctx, id)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("author", id))
}
invalidateEntityType(ctx, r.opt.Cache, "author")
}
return err
}
func (r *CachedAuthorRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
err := r.inner.DeleteInTx(ctx, tx, id)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("author", id))
}
invalidateEntityType(ctx, r.opt.Cache, "author")
}
return err
}
func (r *CachedAuthorRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Author], error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.PaginatedResult[domain.Author]
key := r.opt.Keys.ListKey("author", page, pageSize)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
res, err := r.inner.List(ctx, page, pageSize)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && res != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.ListKey("author", page, pageSize), res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedAuthorRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Author, error) {
return r.inner.ListWithOptions(ctx, options)
}
func (r *CachedAuthorRepository) ListAll(ctx context.Context) ([]domain.Author, error) {
return r.inner.ListAll(ctx)
}
func (r *CachedAuthorRepository) Count(ctx context.Context) (int64, error) {
return r.inner.Count(ctx)
}
func (r *CachedAuthorRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
return r.inner.CountWithOptions(ctx, options)
}
func (r *CachedAuthorRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Author, error) {
return r.inner.FindWithPreload(ctx, preloads, id)
}
func (r *CachedAuthorRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Author, error) {
return r.inner.GetAllForSync(ctx, batchSize, offset)
}
func (r *CachedAuthorRepository) Exists(ctx context.Context, id uint) (bool, error) {
return r.inner.Exists(ctx, id)
}
func (r *CachedAuthorRepository) BeginTx(ctx context.Context) (*gorm.DB, error) {
return r.inner.BeginTx(ctx)
}
func (r *CachedAuthorRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return r.inner.WithTx(ctx, fn)
}
func (r *CachedAuthorRepository) FindByName(ctx context.Context, name string) (*domain.Author, error) {
return r.inner.FindByName(ctx, name)
}
func (r *CachedAuthorRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Author, error) {
return r.inner.ListByWorkID(ctx, workID)
}
func (r *CachedAuthorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) {
return r.inner.ListByBookID(ctx, bookID)
}
func (r *CachedAuthorRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) {
return r.inner.ListByCountryID(ctx, countryID)
}
func (r *CachedAuthorRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Author, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.Author
key := r.opt.Keys.QueryKey("author", "withTranslations", id)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
author, err := r.inner.GetWithTranslations(ctx, id)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && author != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.QueryKey("author", "withTranslations", id), author, r.opt.EntityTTL)
}
return author, nil
}

View File

@ -0,0 +1,241 @@
package cache
import (
"context"
"time"
"tercul/internal/domain"
platform_cache "tercul/internal/platform/cache"
"gorm.io/gorm"
)
type CachedTranslationRepository struct {
inner domain.TranslationRepository
opt Options
}
func NewCachedTranslationRepository(inner domain.TranslationRepository, c platform_cache.Cache, opt *Options) *CachedTranslationRepository {
resolved := DefaultOptions(c)
if opt != nil {
resolved = *opt
if resolved.Cache == nil {
resolved.Cache = c
}
if resolved.Keys == nil {
resolved.Keys = platform_cache.NewDefaultKeyGenerator("tercul:repo:")
}
if resolved.EntityTTL == 0 {
resolved.EntityTTL = 1 * time.Hour
}
if resolved.ListTTL == 0 {
resolved.ListTTL = 5 * time.Minute
}
}
return &CachedTranslationRepository{inner: inner, opt: resolved}
}
func (r *CachedTranslationRepository) Create(ctx context.Context, entity *domain.Translation) error {
err := r.inner.Create(ctx, entity)
if err == nil {
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}
func (r *CachedTranslationRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error {
err := r.inner.CreateInTx(ctx, tx, entity)
if err == nil {
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}
func (r *CachedTranslationRepository) GetByID(ctx context.Context, id uint) (*domain.Translation, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.Translation
key := r.opt.Keys.EntityKey("translation", id)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
tr, err := r.inner.GetByID(ctx, id)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && tr != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.EntityKey("translation", id), tr, r.opt.EntityTTL)
}
return tr, nil
}
func (r *CachedTranslationRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Translation, error) {
return r.inner.GetByIDWithOptions(ctx, id, options)
}
func (r *CachedTranslationRepository) Update(ctx context.Context, entity *domain.Translation) error {
err := r.inner.Update(ctx, entity)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("translation", entity.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}
func (r *CachedTranslationRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error {
err := r.inner.UpdateInTx(ctx, tx, entity)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("translation", entity.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}
func (r *CachedTranslationRepository) Delete(ctx context.Context, id uint) error {
err := r.inner.Delete(ctx, id)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("translation", id))
}
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}
func (r *CachedTranslationRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
err := r.inner.DeleteInTx(ctx, tx, id)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("translation", id))
}
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}
func (r *CachedTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.PaginatedResult[domain.Translation]
key := r.opt.Keys.ListKey("translation", page, pageSize)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
res, err := r.inner.List(ctx, page, pageSize)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && res != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.ListKey("translation", page, pageSize), res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedTranslationRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Translation, error) {
return r.inner.ListWithOptions(ctx, options)
}
func (r *CachedTranslationRepository) ListAll(ctx context.Context) ([]domain.Translation, error) {
return r.inner.ListAll(ctx)
}
func (r *CachedTranslationRepository) Count(ctx context.Context) (int64, error) {
return r.inner.Count(ctx)
}
func (r *CachedTranslationRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
return r.inner.CountWithOptions(ctx, options)
}
func (r *CachedTranslationRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Translation, error) {
return r.inner.FindWithPreload(ctx, preloads, id)
}
func (r *CachedTranslationRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Translation, error) {
return r.inner.GetAllForSync(ctx, batchSize, offset)
}
func (r *CachedTranslationRepository) Exists(ctx context.Context, id uint) (bool, error) {
return r.inner.Exists(ctx, id)
}
func (r *CachedTranslationRepository) BeginTx(ctx context.Context) (*gorm.DB, error) {
return r.inner.BeginTx(ctx)
}
func (r *CachedTranslationRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return r.inner.WithTx(ctx, fn)
}
func (r *CachedTranslationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached []domain.Translation
key := r.opt.Keys.QueryKey("translation", "byWork", workID)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return cached, nil
}
}
res, err := r.inner.ListByWorkID(ctx, workID)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.QueryKey("translation", "byWork", workID), res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedTranslationRepository) ListByWorkIDPaginated(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) {
lang := ""
if language != nil {
lang = *language
}
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.PaginatedResult[domain.Translation]
key := r.opt.Keys.QueryKey("translation", "byWorkPaged", workID, lang, page, pageSize)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
res, err := r.inner.ListByWorkIDPaginated(ctx, workID, language, page, pageSize)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && res != nil {
key := r.opt.Keys.QueryKey("translation", "byWorkPaged", workID, lang, page, pageSize)
_ = r.opt.Cache.Set(ctx, key, res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedTranslationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
return r.inner.ListByEntity(ctx, entityType, entityID)
}
func (r *CachedTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) {
return r.inner.ListByTranslatorID(ctx, translatorID)
}
func (r *CachedTranslationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) {
return r.inner.ListByStatus(ctx, status)
}
func (r *CachedTranslationRepository) Upsert(ctx context.Context, translation *domain.Translation) error {
err := r.inner.Upsert(ctx, translation)
if err == nil {
if r.opt.Cache != nil && translation != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("translation", translation.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "translation")
}
return err
}

View File

@ -0,0 +1,278 @@
package cache
import (
"context"
"time"
"tercul/internal/domain"
platform_cache "tercul/internal/platform/cache"
"gorm.io/gorm"
)
type CachedWorkRepository struct {
inner domain.WorkRepository
opt Options
}
func NewCachedWorkRepository(inner domain.WorkRepository, c platform_cache.Cache, opt *Options) *CachedWorkRepository {
resolved := DefaultOptions(c)
if opt != nil {
resolved = *opt
if resolved.Cache == nil {
resolved.Cache = c
}
if resolved.Keys == nil {
resolved.Keys = platform_cache.NewDefaultKeyGenerator("tercul:repo:")
}
if resolved.EntityTTL == 0 {
resolved.EntityTTL = 1 * time.Hour
}
if resolved.ListTTL == 0 {
resolved.ListTTL = 5 * time.Minute
}
}
return &CachedWorkRepository{inner: inner, opt: resolved}
}
func (r *CachedWorkRepository) Create(ctx context.Context, entity *domain.Work) error {
err := r.inner.Create(ctx, entity)
if err == nil {
invalidateEntityType(ctx, r.opt.Cache, "work")
}
return err
}
func (r *CachedWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error {
err := r.inner.CreateInTx(ctx, tx, entity)
if err == nil {
invalidateEntityType(ctx, r.opt.Cache, "work")
}
return err
}
func (r *CachedWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.Work
key := r.opt.Keys.EntityKey("work", id)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
work, err := r.inner.GetByID(ctx, id)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && work != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.EntityKey("work", id), work, r.opt.EntityTTL)
}
return work, nil
}
func (r *CachedWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) {
// Options can include varying preloads/where/order; avoid caching to prevent incorrect results.
return r.inner.GetByIDWithOptions(ctx, id, options)
}
func (r *CachedWorkRepository) Update(ctx context.Context, entity *domain.Work) error {
err := r.inner.Update(ctx, entity)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("work", entity.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "work")
}
return err
}
func (r *CachedWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error {
err := r.inner.UpdateInTx(ctx, tx, entity)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("work", entity.ID))
}
invalidateEntityType(ctx, r.opt.Cache, "work")
}
return err
}
func (r *CachedWorkRepository) Delete(ctx context.Context, id uint) error {
err := r.inner.Delete(ctx, id)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("work", id))
}
invalidateEntityType(ctx, r.opt.Cache, "work")
}
return err
}
func (r *CachedWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
err := r.inner.DeleteInTx(ctx, tx, id)
if err == nil {
if r.opt.Cache != nil {
_ = r.opt.Cache.Delete(ctx, r.opt.Keys.EntityKey("work", id))
}
invalidateEntityType(ctx, r.opt.Cache, "work")
}
return err
}
func (r *CachedWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.PaginatedResult[domain.Work]
key := r.opt.Keys.ListKey("work", page, pageSize)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
res, err := r.inner.List(ctx, page, pageSize)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && res != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.ListKey("work", page, pageSize), res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) {
return r.inner.ListWithOptions(ctx, options)
}
func (r *CachedWorkRepository) ListAll(ctx context.Context) ([]domain.Work, error) {
return r.inner.ListAll(ctx)
}
func (r *CachedWorkRepository) Count(ctx context.Context) (int64, error) {
return r.inner.Count(ctx)
}
func (r *CachedWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
return r.inner.CountWithOptions(ctx, options)
}
func (r *CachedWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) {
return r.inner.FindWithPreload(ctx, preloads, id)
}
func (r *CachedWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) {
return r.inner.GetAllForSync(ctx, batchSize, offset)
}
func (r *CachedWorkRepository) Exists(ctx context.Context, id uint) (bool, error) {
return r.inner.Exists(ctx, id)
}
func (r *CachedWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) {
return r.inner.BeginTx(ctx)
}
func (r *CachedWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return r.inner.WithTx(ctx, fn)
}
func (r *CachedWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) {
return r.inner.FindByTitle(ctx, title)
}
func (r *CachedWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) {
return r.inner.FindByAuthor(ctx, authorID)
}
func (r *CachedWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) {
return r.inner.FindByCategory(ctx, categoryID)
}
func (r *CachedWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.PaginatedResult[domain.Work]
key := r.opt.Keys.QueryKey("work", "lang", language, page, pageSize)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
res, err := r.inner.FindByLanguage(ctx, language, page, pageSize)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && res != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.QueryKey("work", "lang", language, page, pageSize), res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.Work
key := r.opt.Keys.QueryKey("work", "withTranslations", id)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
work, err := r.inner.GetWithTranslations(ctx, id)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && work != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.QueryKey("work", "withTranslations", id), work, r.opt.EntityTTL)
}
return work, nil
}
func (r *CachedWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*domain.Work, error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.Work
key := r.opt.Keys.QueryKey("work", "withAssociations", id)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
work, err := r.inner.GetWithAssociations(ctx, id)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && work != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.QueryKey("work", "withAssociations", id), work, r.opt.EntityTTL)
}
return work, nil
}
func (r *CachedWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*domain.Work, error) {
// Tx-scoped reads should bypass cache.
return r.inner.GetWithAssociationsInTx(ctx, tx, id)
}
func (r *CachedWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
if r.opt.Enabled && r.opt.Cache != nil {
var cached domain.PaginatedResult[domain.Work]
key := r.opt.Keys.QueryKey("work", "listWithTranslations", page, pageSize)
if err := r.opt.Cache.Get(ctx, key, &cached); err == nil {
return &cached, nil
}
}
res, err := r.inner.ListWithTranslations(ctx, page, pageSize)
if err != nil {
return nil, err
}
if r.opt.Enabled && r.opt.Cache != nil && res != nil {
_ = r.opt.Cache.Set(ctx, r.opt.Keys.QueryKey("work", "listWithTranslations", page, pageSize), res, r.opt.ListTTL)
}
return res, nil
}
func (r *CachedWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
return r.inner.IsAuthor(ctx, workID, authorID)
}
func (r *CachedWorkRepository) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) {
return r.inner.ListByCollectionID(ctx, collectionID)
}

20
internal/data/cache/invalidate.go vendored Normal file
View File

@ -0,0 +1,20 @@
package cache
import (
"context"
platform_cache "tercul/internal/platform/cache"
)
type entityTypeInvalidator interface {
InvalidateEntityType(ctx context.Context, entityType string) error
}
func invalidateEntityType(ctx context.Context, c platform_cache.Cache, entityType string) {
if c == nil {
return
}
if inv, ok := c.(entityTypeInvalidator); ok {
_ = inv.InvalidateEntityType(ctx, entityType)
}
}

25
internal/data/cache/options.go vendored Normal file
View File

@ -0,0 +1,25 @@
package cache
import (
"time"
platform_cache "tercul/internal/platform/cache"
)
type Options struct {
Enabled bool
Cache platform_cache.Cache
Keys platform_cache.KeyGenerator
EntityTTL time.Duration
ListTTL time.Duration
}
func DefaultOptions(c platform_cache.Cache) Options {
return Options{
Enabled: c != nil,
Cache: c,
Keys: platform_cache.NewDefaultKeyGenerator("tercul:repo:"),
EntityTTL: 1 * time.Hour,
ListTTL: 5 * time.Minute,
}
}

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

111
test/e2e/auth_e2e_test.go Normal file
View File

@ -0,0 +1,111 @@
package e2e
// TestUserRegistrationFlow tests the complete user registration flow.
func (s *E2ETestSuite) TestUserRegistrationFlow() {
mutation := `
mutation Register($input: RegisterInput!) {
register(input: $input) {
token
user {
id
username
email
role
}
}
}
`
variables := map[string]interface{}{
"input": map[string]interface{}{
"username": "newuser",
"email": "newuser@test.com",
"password": "password123",
"firstName": "New",
"lastName": "User",
},
}
resp := s.executeGraphQL(mutation, variables, "")
s.Require().NotNil(resp)
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
register := resp["data"].(map[string]interface{})["register"].(map[string]interface{})
token := register["token"].(string)
s.NotEmpty(token)
user := register["user"].(map[string]interface{})
s.Equal("newuser", user["username"])
s.Equal("newuser@test.com", user["email"])
s.Equal("READER", user["role"])
var count int64
s.DB.Table("users").Where("username = ?", "newuser").Count(&count)
s.Equal(int64(1), count)
}
// TestUserLoginFlow tests login and authenticated "me" query.
func (s *E2ETestSuite) TestUserLoginFlow() {
mutation := `
mutation Login($input: LoginInput!) {
login(input: $input) {
token
user { id username email role }
}
}
`
variables := map[string]interface{}{
"input": map[string]interface{}{
"email": "admin@tercul.com",
"password": "admin123",
},
}
resp := s.executeGraphQL(mutation, variables, "")
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
login := resp["data"].(map[string]interface{})["login"].(map[string]interface{})
token := login["token"].(string)
s.NotEmpty(token)
meQuery := `query { me { id username email role } }`
meResp := s.executeGraphQL(meQuery, nil, token)
s.Require().NotNil(meResp["data"])
s.Require().Nil(meResp["errors"])
me := meResp["data"].(map[string]interface{})["me"].(map[string]interface{})
s.Equal("admin", me["username"])
s.Equal("admin@tercul.com", me["email"])
s.Equal("ADMIN", me["role"])
}
// TestInvalidCredentials tests login failure with incorrect password.
func (s *E2ETestSuite) TestInvalidCredentials() {
mutation := `
mutation Login($input: LoginInput!) {
login(input: $input) {
token
}
}
`
variables := map[string]interface{}{
"input": map[string]interface{}{
"email": "admin@tercul.com",
"password": "wrongpassword",
},
}
resp := s.executeGraphQL(mutation, variables, "")
s.Require().NotNil(resp)
s.Require().NotNil(resp["errors"], "expected GraphQL errors")
}
// TestUnauthenticatedAccess tests that "me" requires authentication.
func (s *E2ETestSuite) TestUnauthenticatedAccess() {
query := `query { me { id username } }`
resp := s.executeGraphQL(query, nil, "")
s.Require().NotNil(resp)
s.Require().NotNil(resp["errors"], "expected authentication error")
}

138
test/e2e/e2e_test.go Normal file
View File

@ -0,0 +1,138 @@
package e2e
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
graph "tercul/internal/adapters/graphql"
"tercul/internal/domain"
"tercul/internal/observability"
platform_auth "tercul/internal/platform/auth"
platform_config "tercul/internal/platform/config"
"tercul/internal/testutil"
"tercul/test/fixtures"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/suite"
"gorm.io/gorm/logger"
)
// E2ETestSuite provides end-to-end testing infrastructure.
type E2ETestSuite struct {
testutil.IntegrationTestSuite
Server *httptest.Server
Client *http.Client
JWTManager *platform_auth.JWTManager
Fixtures *fixtures.Loader
}
func (s *E2ETestSuite) SetupSuite() {
// Use a file-based SQLite DB for stability across multiple connections.
// In-memory SQLite (":memory:") can create isolated DBs per connection, which breaks HTTP handler requests.
s.IntegrationTestSuite.SetupSuite(&testutil.TestConfig{UseInMemoryDB: false, DBPath: "e2e_test.db", LogLevel: logger.Silent})
cfg, err := platform_config.LoadConfig()
s.Require().NoError(err)
s.JWTManager = platform_auth.NewJWTManager(cfg)
s.Fixtures = fixtures.NewLoader(s.DB)
resolver := &graph.Resolver{App: s.App}
c := graph.Config{Resolvers: resolver}
c.Directives.Binding = graph.Binding
//nolint:staticcheck // Required here for custom error presenter
srv := handler.NewDefaultServer(graph.NewExecutableSchema(c))
srv.SetErrorPresenter(graph.NewErrorPresenter())
reg := prometheus.NewRegistry()
metrics := observability.NewMetrics(reg)
var chain http.Handler
chain = srv
chain = platform_auth.GraphQLAuthMiddleware(s.JWTManager)(chain)
chain = metrics.PrometheusMiddleware(chain)
chain = observability.TracingMiddleware(chain)
chain = observability.RequestIDMiddleware(chain)
s.Server = httptest.NewServer(chain)
s.Client = s.Server.Client()
}
func (s *E2ETestSuite) TearDownSuite() {
if s.Server != nil {
s.Server.Close()
}
s.IntegrationTestSuite.TearDownSuite()
}
func (s *E2ETestSuite) SetupTest() {
ctx := context.Background()
s.Require().NoError(s.Fixtures.Clear(ctx))
s.Require().NoError(s.Fixtures.LoadAll(ctx))
}
func (s *E2ETestSuite) TearDownTest() {
ctx := context.Background()
_ = s.Fixtures.Clear(ctx)
}
func (s *E2ETestSuite) GenerateToken(username string) string {
var user domain.User
err := s.DB.Where("username = ?", username).First(&user).Error
s.Require().NoError(err)
token, err := s.JWTManager.GenerateToken(&user)
s.Require().NoError(err)
return token
}
func (s *E2ETestSuite) executeGraphQL(query string, variables map[string]interface{}, token string) map[string]interface{} {
payload := map[string]interface{}{"query": query}
if variables != nil {
payload["variables"] = variables
}
body, err := json.Marshal(payload)
s.Require().NoError(err)
req, err := http.NewRequest("POST", s.Server.URL, bytes.NewBuffer(body))
s.Require().NoError(err)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := s.Client.Do(req)
s.Require().NoError(err)
defer resp.Body.Close()
var out map[string]interface{}
s.Require().NoError(json.NewDecoder(resp.Body).Decode(&out))
return out
}
func (s *E2ETestSuite) Eventually(condition func() bool, timeout time.Duration, message string) {
start := time.Now()
for {
if condition() {
return
}
if time.Since(start) > timeout {
s.Fail(message)
return
}
time.Sleep(25 * time.Millisecond)
}
}
func TestE2ETestSuite(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E tests in short mode")
}
suite.Run(t, new(E2ETestSuite))
}

View File

@ -0,0 +1,95 @@
package e2e
// TestTranslationQueryFlow tests querying a translation from fixtures.
func (s *E2ETestSuite) TestTranslationQueryFlow() {
query := `
query GetTranslation($id: ID!) {
translation(id: $id) {
id
name
language
workId
content
}
}
`
variables := map[string]interface{}{"id": "1"}
resp := s.executeGraphQL(query, variables, "")
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
tr := resp["data"].(map[string]interface{})["translation"].(map[string]interface{})
s.Equal("1", tr["id"])
s.Equal("en", tr["language"])
s.Equal("1", tr["workId"])
s.NotEmpty(tr["content"].(string))
}
// TestTranslationsForWork tests listing translations for a work.
func (s *E2ETestSuite) TestTranslationsForWork() {
query := `query { translations(workId: "1", limit: 20, offset: 0) { id language name } }`
resp := s.executeGraphQL(query, nil, "")
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
trs := resp["data"].(map[string]interface{})["translations"].([]interface{})
s.GreaterOrEqual(len(trs), 2)
}
// TestContributorTranslationLifecycle tests that a contributor can create a work, add a translation,
// then delete their own translation.
func (s *E2ETestSuite) TestContributorTranslationLifecycle() {
contributorToken := s.GenerateToken("contributor")
createWork := `
mutation CreateWork($input: WorkInput!) {
createWork(input: $input) { id name language }
}
`
workVars := map[string]interface{}{
"input": map[string]interface{}{
"name": "Contributor Work",
"language": "en",
"content": "Contributor work content",
},
}
workResp := s.executeGraphQL(createWork, workVars, contributorToken)
s.Require().NotNil(workResp["data"])
s.Require().Nil(workResp["errors"])
work := workResp["data"].(map[string]interface{})["createWork"].(map[string]interface{})
workID := work["id"].(string)
s.NotEmpty(workID)
createTranslation := `
mutation CreateTranslation($input: TranslationInput!) {
createTranslation(input: $input) { id name language workId }
}
`
trVars := map[string]interface{}{
"input": map[string]interface{}{
"name": "Contributor Translation",
"language": "fr",
"content": "Bonjour",
"workId": workID,
},
}
trResp := s.executeGraphQL(createTranslation, trVars, contributorToken)
s.Require().NotNil(trResp["data"])
s.Require().Nil(trResp["errors"])
tr := trResp["data"].(map[string]interface{})["createTranslation"].(map[string]interface{})
translationID := tr["id"].(string)
s.NotEmpty(translationID)
s.Equal(workID, tr["workId"].(string))
deleteMutation := `mutation DeleteTranslation($id: ID!) { deleteTranslation(id: $id) }`
deleteVars := map[string]interface{}{"id": translationID}
deleteResp := s.executeGraphQL(deleteMutation, deleteVars, contributorToken)
s.Require().NotNil(deleteResp["data"])
s.Require().Nil(deleteResp["errors"])
s.True(deleteResp["data"].(map[string]interface{})["deleteTranslation"].(bool))
var count int64
s.DB.Table("translations").Where("id = ?", translationID).Count(&count)
s.Equal(int64(0), count)
}

120
test/e2e/work_e2e_test.go Normal file
View File

@ -0,0 +1,120 @@
package e2e
// TestWorkQueryFlow tests querying a work from fixtures.
func (s *E2ETestSuite) TestWorkQueryFlow() {
query := `
query GetWork($id: ID!) {
work(id: $id) {
id
name
language
content
}
}
`
variables := map[string]interface{}{"id": "1"}
resp := s.executeGraphQL(query, variables, "")
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
work := resp["data"].(map[string]interface{})["work"].(map[string]interface{})
s.Equal("1", work["id"])
s.Equal("War and Peace", work["name"])
s.Equal("ru", work["language"])
// Content may be the fixture description fallback.
if work["content"] != nil {
s.Contains(work["content"].(string), "Epic historical novel")
}
}
// TestWorkListFlow tests listing works.
func (s *E2ETestSuite) TestWorkListFlow() {
query := `query { works(limit: 20, offset: 0) { id name language } }`
resp := s.executeGraphQL(query, nil, "")
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
works := resp["data"].(map[string]interface{})["works"].([]interface{})
s.GreaterOrEqual(len(works), 4)
}
// TestCreateWorkFlow tests creating a work (requires authentication).
func (s *E2ETestSuite) TestCreateWorkFlow() {
mutation := `
mutation CreateWork($input: WorkInput!) {
createWork(input: $input) { id name language }
}
`
variables := map[string]interface{}{
"input": map[string]interface{}{
"name": "New Test Work",
"language": "en",
"content": "Hello world content",
},
}
token := s.GenerateToken("contributor")
resp := s.executeGraphQL(mutation, variables, token)
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
created := resp["data"].(map[string]interface{})["createWork"].(map[string]interface{})
s.NotEmpty(created["id"].(string))
s.Equal("New Test Work", created["name"])
s.Equal("en", created["language"])
}
// TestUpdateWorkFlow tests updating a work (admin can update any work).
func (s *E2ETestSuite) TestUpdateWorkFlow() {
mutation := `
mutation UpdateWork($id: ID!, $input: WorkInput!) {
updateWork(id: $id, input: $input) { id name language }
}
`
variables := map[string]interface{}{
"id": "1",
"input": map[string]interface{}{
"name": "War and Peace (Updated)",
"language": "ru",
},
}
token := s.GenerateToken("admin")
resp := s.executeGraphQL(mutation, variables, token)
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
updated := resp["data"].(map[string]interface{})["updateWork"].(map[string]interface{})
s.Equal("1", updated["id"])
s.Equal("War and Peace (Updated)", updated["name"])
}
// TestDeleteWorkFlow tests deleting a work (admin can delete any work).
func (s *E2ETestSuite) TestDeleteWorkFlow() {
mutation := `mutation { deleteWork(id: "4") }`
token := s.GenerateToken("admin")
resp := s.executeGraphQL(mutation, nil, token)
s.Require().NotNil(resp["data"])
s.Require().Nil(resp["errors"])
deleted := resp["data"].(map[string]interface{})["deleteWork"].(bool)
s.True(deleted)
var count int64
s.DB.Table("works").Where("id = ?", 4).Count(&count)
s.Equal(int64(0), count)
}
// TestWorkPermissions tests that a non-admin non-author cannot delete a work.
func (s *E2ETestSuite) TestWorkPermissions() {
mutation := `mutation { deleteWork(id: "1") }`
token := s.GenerateToken("reader")
resp := s.executeGraphQL(mutation, nil, token)
s.Require().NotNil(resp["errors"], "expected authorization error")
var count int64
s.DB.Table("works").Where("id = ?", 1).Count(&count)
s.Equal(int64(1), count)
}

1
test/fixtures/.keep vendored
View File

@ -1 +0,0 @@
# This file is created to ensure the directory structure is in place.

96
test/fixtures/authors.go vendored Normal file
View File

@ -0,0 +1,96 @@
package fixtures
import (
"context"
"tercul/internal/domain"
"time"
"gorm.io/gorm"
)
// Author fixtures for testing
var (
// TolstoyAuthor is a fixture for Leo Tolstoy
TolstoyAuthor = domain.Author{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 1,
CreatedAt: time.Now().Add(-365 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-30 * 24 * time.Hour),
},
Language: "en",
},
Name: "Leo Tolstoy",
BirthDate: timePtr("1828-09-09T00:00:00Z"),
DeathDate: timePtr("1910-11-20T00:00:00Z"),
}
// EliotAuthor is a fixture for T.S. Eliot
EliotAuthor = domain.Author{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 2,
CreatedAt: time.Now().Add(-180 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-10 * 24 * time.Hour),
},
Language: "en",
},
Name: "T.S. Eliot",
BirthDate: timePtr("1888-09-26T00:00:00Z"),
DeathDate: timePtr("1965-01-04T00:00:00Z"),
}
// KafkaAuthor is a fixture for Franz Kafka
KafkaAuthor = domain.Author{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 3,
CreatedAt: time.Now().Add(-90 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-5 * 24 * time.Hour),
},
Language: "en",
},
Name: "Franz Kafka",
BirthDate: timePtr("1883-07-03T00:00:00Z"),
DeathDate: timePtr("1924-06-03T00:00:00Z"),
}
// ContemporaryAuthor is a fixture for a contemporary author
ContemporaryAuthor = domain.Author{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 4,
CreatedAt: time.Now().Add(-30 * 24 * time.Hour),
UpdatedAt: time.Now(),
},
Language: "en",
},
Name: "Jane Modern",
BirthDate: timePtr("1985-03-15T00:00:00Z"),
}
)
// AllAuthors returns all author fixtures
func AllAuthors() []domain.Author {
return []domain.Author{
TolstoyAuthor,
EliotAuthor,
KafkaAuthor,
ContemporaryAuthor,
}
}
// LoadAuthors loads author fixtures into the database
func LoadAuthors(ctx context.Context, db *gorm.DB) error {
for _, author := range AllAuthors() {
if err := db.Create(&author).Error; err != nil {
return err
}
}
return nil
}
func timePtr(s string) *time.Time {
t, _ := time.Parse(time.RFC3339, s)
return &t
}

59
test/fixtures/loader.go vendored Normal file
View File

@ -0,0 +1,59 @@
package fixtures
import (
"context"
"fmt"
"gorm.io/gorm"
)
// Loader provides methods to load fixtures into the database
type Loader struct {
db *gorm.DB
}
// NewLoader creates a new fixture loader
func NewLoader(db *gorm.DB) *Loader {
return &Loader{db: db}
}
// LoadAll loads all fixtures into the database in the correct order
func (l *Loader) LoadAll(ctx context.Context) error {
// Load in order to respect foreign key constraints
loaders := []struct {
name string
fn func(context.Context, *gorm.DB) error
}{
{"users", LoadUsers},
{"authors", LoadAuthors},
{"works", LoadWorks},
{"translations", LoadTranslations},
}
for _, loader := range loaders {
if err := loader.fn(ctx, l.db); err != nil {
return fmt.Errorf("failed to load %s: %w", loader.name, err)
}
}
return nil
}
// Clear removes all fixture data from the database
func (l *Loader) Clear(ctx context.Context) error {
// Delete in reverse order to respect foreign key constraints
tables := []string{
"translations",
"works",
"authors",
"users",
}
for _, table := range tables {
if err := l.db.Exec(fmt.Sprintf("DELETE FROM %s", table)).Error; err != nil {
return fmt.Errorf("failed to clear %s: %w", table, err)
}
}
return nil
}

117
test/fixtures/translations.go vendored Normal file
View File

@ -0,0 +1,117 @@
package fixtures
import (
"context"
"tercul/internal/domain"
"time"
"gorm.io/gorm"
)
// Translation fixtures for testing
var (
// WarAndPeaceEnglishTranslation is a fixture for War and Peace English translation
WarAndPeaceEnglishTranslation = domain.Translation{
BaseModel: domain.BaseModel{
ID: 1,
CreatedAt: time.Now().Add(-300 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-20 * 24 * time.Hour),
},
TranslatableID: 1, // War and Peace
TranslatableType: "works",
TranslatorID: uintPtr(2), // Editor
Language: "en",
Title: "War and Peace",
Content: "Well, Prince, so Genoa and Lucca are now just family estates of the Buonapartes...",
Status: domain.TranslationStatusPublished,
}
// WarAndPeaceFrenchTranslation is a fixture for War and Peace French translation
WarAndPeaceFrenchTranslation = domain.Translation{
BaseModel: domain.BaseModel{
ID: 2,
CreatedAt: time.Now().Add(-250 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-15 * 24 * time.Hour),
},
TranslatableID: 1, // War and Peace
TranslatableType: "works",
TranslatorID: uintPtr(3), // Contributor
Language: "fr",
Title: "Guerre et Paix",
Content: "Eh bien, mon prince, Gênes et Lucques ne sont plus que des apanages...",
Status: domain.TranslationStatusPublished,
}
// MetamorphosisEnglishTranslation is a fixture for Metamorphosis English translation
MetamorphosisEnglishTranslation = domain.Translation{
BaseModel: domain.BaseModel{
ID: 3,
CreatedAt: time.Now().Add(-80 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-3 * 24 * time.Hour),
},
TranslatableID: 3, // The Metamorphosis
TranslatableType: "works",
TranslatorID: uintPtr(2), // Editor
Language: "en",
Title: "The Metamorphosis",
Content: "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed...",
Status: domain.TranslationStatusPublished,
}
// DraftTranslation is a fixture for a draft translation
DraftTranslation = domain.Translation{
BaseModel: domain.BaseModel{
ID: 4,
CreatedAt: time.Now().Add(-5 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-2 * time.Hour),
},
TranslatableID: 2, // The Waste Land
TranslatableType: "works",
TranslatorID: uintPtr(3), // Contributor
Language: "es",
Title: "La tierra baldía",
Content: "Abril es el mes más cruel...",
Status: domain.TranslationStatusDraft,
}
// ReviewingTranslation is a fixture for a translation under review
ReviewingTranslation = domain.Translation{
BaseModel: domain.BaseModel{
ID: 5,
CreatedAt: time.Now().Add(-10 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * 24 * time.Hour),
},
TranslatableID: 2, // The Waste Land
TranslatableType: "works",
TranslatorID: uintPtr(3), // Contributor
Language: "de",
Title: "Das wüste Land",
Content: "April ist der grausamste Monat...",
Status: domain.TranslationStatusReviewing,
}
)
func uintPtr(u uint) *uint {
return &u
}
// AllTranslations returns all translation fixtures
func AllTranslations() []domain.Translation {
return []domain.Translation{
WarAndPeaceEnglishTranslation,
WarAndPeaceFrenchTranslation,
MetamorphosisEnglishTranslation,
DraftTranslation,
ReviewingTranslation,
}
}
// LoadTranslations loads translation fixtures into the database
func LoadTranslations(ctx context.Context, db *gorm.DB) error {
for _, translation := range AllTranslations() {
if err := db.Create(&translation).Error; err != nil {
return err
}
}
return nil
}

131
test/fixtures/users.go vendored Normal file
View File

@ -0,0 +1,131 @@
package fixtures
import (
"context"
"tercul/internal/domain"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// User fixtures for testing
var (
// AdminUser is a fixture for an admin user
AdminUser = domain.User{
BaseModel: domain.BaseModel{
ID: 1,
CreatedAt: time.Now().Add(-30 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * 24 * time.Hour),
},
Username: "admin",
Email: "admin@tercul.com",
Password: hashPassword("admin123"),
FirstName: "Admin",
LastName: "User",
DisplayName: "Admin",
Bio: "Platform administrator",
Role: domain.UserRoleAdmin,
Verified: true,
Active: true,
}
// EditorUser is a fixture for an editor user
EditorUser = domain.User{
BaseModel: domain.BaseModel{
ID: 2,
CreatedAt: time.Now().Add(-20 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-2 * 24 * time.Hour),
},
Username: "editor",
Email: "editor@tercul.com",
Password: hashPassword("editor123"),
FirstName: "Editor",
LastName: "User",
DisplayName: "Editor",
Bio: "Content editor",
Role: domain.UserRoleEditor,
Verified: true,
Active: true,
}
// ContributorUser is a fixture for a contributor user
ContributorUser = domain.User{
BaseModel: domain.BaseModel{
ID: 3,
CreatedAt: time.Now().Add(-15 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
},
Username: "contributor",
Email: "contributor@tercul.com",
Password: hashPassword("contributor123"),
FirstName: "Contributor",
LastName: "User",
DisplayName: "Contributor",
Bio: "Active contributor",
Role: domain.UserRoleContributor,
Verified: true,
Active: true,
}
// ReaderUser is a fixture for a reader user
ReaderUser = domain.User{
BaseModel: domain.BaseModel{
ID: 4,
CreatedAt: time.Now().Add(-10 * 24 * time.Hour),
UpdatedAt: time.Now(),
},
Username: "reader",
Email: "reader@tercul.com",
Password: hashPassword("reader123"),
FirstName: "Reader",
LastName: "User",
DisplayName: "Reader",
Bio: "Book enthusiast",
Role: domain.UserRoleReader,
Verified: true,
Active: true,
}
// InactiveUser is a fixture for an inactive user
InactiveUser = domain.User{
BaseModel: domain.BaseModel{
ID: 5,
CreatedAt: time.Now().Add(-5 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-3 * 24 * time.Hour),
},
Username: "inactive",
Email: "inactive@tercul.com",
Password: hashPassword("inactive123"),
Role: domain.UserRoleReader,
Verified: false,
Active: false,
}
)
// AllUsers returns all user fixtures
func AllUsers() []domain.User {
return []domain.User{
AdminUser,
EditorUser,
ContributorUser,
ReaderUser,
InactiveUser,
}
}
// hashPassword hashes a password for testing
func hashPassword(password string) string {
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash)
}
// LoadUsers loads user fixtures into the database
func LoadUsers(ctx context.Context, db *gorm.DB) error {
for _, user := range AllUsers() {
if err := db.Create(&user).Error; err != nil {
return err
}
}
return nil
}

96
test/fixtures/works.go vendored Normal file
View File

@ -0,0 +1,96 @@
package fixtures
import (
"context"
"tercul/internal/domain"
"time"
"gorm.io/gorm"
)
// Work fixtures for testing
var (
// ClassicNovel is a fixture for a classic novel
ClassicNovel = domain.Work{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 1,
CreatedAt: time.Now().Add(-365 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-30 * 24 * time.Hour),
},
Language: "ru",
},
Title: "War and Peace",
Description: "Epic historical novel by Leo Tolstoy",
Type: "novel",
Status: "published",
}
// ModernPoetry is a fixture for modern poetry
ModernPoetry = domain.Work{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 2,
CreatedAt: time.Now().Add(-180 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-10 * 24 * time.Hour),
},
Language: "en",
},
Title: "The Waste Land",
Description: "Modernist poem by T.S. Eliot",
Type: "poetry",
Status: "published",
}
// ShortStory is a fixture for a short story
ShortStory = domain.Work{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 3,
CreatedAt: time.Now().Add(-90 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-5 * 24 * time.Hour),
},
Language: "de",
},
Title: "The Metamorphosis",
Description: "Novella by Franz Kafka",
Type: "novella",
Status: "published",
}
// DraftWork is a fixture for a work in draft status
DraftWork = domain.Work{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{
ID: 4,
CreatedAt: time.Now().Add(-7 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
},
Language: "en",
},
Title: "Untitled Work",
Description: "Work in progress",
Type: "novel",
Status: "draft",
}
)
// AllWorks returns all work fixtures
func AllWorks() []domain.Work {
return []domain.Work{
ClassicNovel,
ModernPoetry,
ShortStory,
DraftWork,
}
}
// LoadWorks loads work fixtures into the database
func LoadWorks(ctx context.Context, db *gorm.DB) error {
for _, work := range AllWorks() {
if err := db.Create(&work).Error; err != nil {
return err
}
}
return nil
}