mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 04:01:34 +00:00
chore: remove legacy .keep placeholders from unused ops/adapters/pkg dirs
This commit is contained in:
parent
ad749d9184
commit
6fdf0a97fd
@ -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")
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
210
internal/data/cache/cached_author_repository.go
vendored
Normal file
210
internal/data/cache/cached_author_repository.go
vendored
Normal 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
|
||||
}
|
||||
241
internal/data/cache/cached_translation_repository.go
vendored
Normal file
241
internal/data/cache/cached_translation_repository.go
vendored
Normal 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
|
||||
}
|
||||
278
internal/data/cache/cached_work_repository.go
vendored
Normal file
278
internal/data/cache/cached_work_repository.go
vendored
Normal 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
20
internal/data/cache/invalidate.go
vendored
Normal 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
25
internal/data/cache/options.go
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
111
test/e2e/auth_e2e_test.go
Normal file
111
test/e2e/auth_e2e_test.go
Normal 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
138
test/e2e/e2e_test.go
Normal 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))
|
||||
}
|
||||
95
test/e2e/translation_e2e_test.go
Normal file
95
test/e2e/translation_e2e_test.go
Normal 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
120
test/e2e/work_e2e_test.go
Normal 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
1
test/fixtures/.keep
vendored
@ -1 +0,0 @@
|
||||
# This file is created to ensure the directory structure is in place.
|
||||
96
test/fixtures/authors.go
vendored
Normal file
96
test/fixtures/authors.go
vendored
Normal 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
59
test/fixtures/loader.go
vendored
Normal 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
117
test/fixtures/translations.go
vendored
Normal 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
131
test/fixtures/users.go
vendored
Normal 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
96
test/fixtures/works.go
vendored
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user