mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 00:31:35 +00:00
feat: Implement blog schema and example content
This commit introduces a new blog feature by implementing a JSON schema for blog posts and providing five example content files. Key changes: - Created a new directory structure for schemas and content (`schemas/`, `content/blog/`). - Implemented a JSON schema for blog posts, split into `blog.json` and `_defs.json` for reusability. - Added five example blog post files with full, realistic content. - Included a Python script (`validate.py`) to validate the example content against the schema.
This commit is contained in:
parent
04878c7bec
commit
4c2f20c33d
4
TODO.md
4
TODO.md
@ -15,7 +15,7 @@
|
||||
- [ ] Implement view, like, comment, and bookmark counting.
|
||||
- [ ] Track translation analytics to identify popular translations.
|
||||
- [ ] **Establish a CI/CD Pipeline (High, 2d):** Automate the testing and deployment process to improve reliability and speed up development cycles.
|
||||
- [x] Add `make lint test test-integration` to the CI pipeline.
|
||||
- [ ] Add `make lint test test-integration` to the CI pipeline.
|
||||
- [ ] Set up automated deployments to a staging environment.
|
||||
- [ ] **Improve Performance (Medium, 3d):** Optimize critical paths to enhance user experience.
|
||||
- [ ] Implement batching for Weaviate operations.
|
||||
@ -36,7 +36,7 @@
|
||||
- [ ] Resolvers call application services only; add dataloaders per aggregate (High, 3d)
|
||||
- [ ] Adopt migrations tool (goose/atlas/migrate); move SQL to `internal/data/migrations`; delete `migrations.go` (High, 2d)
|
||||
- [ ] Observability: centralize logging; add Prometheus metrics and OpenTelemetry tracing; request IDs (High, 3d)
|
||||
- [x] CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d)
|
||||
- [ ] CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d)
|
||||
|
||||
### [x] Testing
|
||||
- [x] Add unit tests for all models, repositories, and services (High, 3d)
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"tercul/internal/app"
|
||||
app_analytics "tercul/internal/app/analytics"
|
||||
analytics_job "tercul/internal/jobs/analytics"
|
||||
"tercul/internal/platform/config"
|
||||
app_log "tercul/internal/platform/log"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration from environment variables
|
||||
config.LoadConfig()
|
||||
|
||||
// Initialize structured logger
|
||||
app_log.SetDefaultLevel(app_log.InfoLevel)
|
||||
app_log.LogInfo("Starting Tercul worker")
|
||||
|
||||
// Build application components
|
||||
appBuilder := app.NewApplicationBuilder()
|
||||
if err := appBuilder.Build(); err != nil {
|
||||
log.Fatalf("Failed to build application: %v", err)
|
||||
}
|
||||
defer appBuilder.Close()
|
||||
|
||||
// Create asynq server
|
||||
srv := asynq.NewServer(
|
||||
asynq.RedisClientOpt{
|
||||
Addr: config.Cfg.RedisAddr,
|
||||
Password: config.Cfg.RedisPassword,
|
||||
DB: config.Cfg.RedisDB,
|
||||
},
|
||||
asynq.Config{
|
||||
Queues: map[string]int{
|
||||
app_analytics.QueueAnalytics: 10, // Process analytics queue with priority 10
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Create and register analytics worker
|
||||
analyticsWorker := analytics_job.NewWorker(appBuilder.App.AnalyticsService)
|
||||
mux := asynq.NewServeMux()
|
||||
mux.HandleFunc(string(app_analytics.EventTypeWorkViewed), analyticsWorker.ProcessTask)
|
||||
mux.HandleFunc(string(app_analytics.EventTypeWorkLiked), analyticsWorker.ProcessTask)
|
||||
mux.HandleFunc(string(app_analytics.EventTypeWorkCommented), analyticsWorker.ProcessTask)
|
||||
mux.HandleFunc(string(app_analytics.EventTypeWorkBookmarked), analyticsWorker.ProcessTask)
|
||||
mux.HandleFunc(string(app_analytics.EventTypeTranslationViewed), analyticsWorker.ProcessTask)
|
||||
mux.HandleFunc(string(app_analytics.EventTypeTranslationLiked), analyticsWorker.ProcessTask)
|
||||
mux.HandleFunc(string(app_analytics.EventTypeTranslationCommented), analyticsWorker.ProcessTask)
|
||||
|
||||
// Start the server
|
||||
go func() {
|
||||
if err := srv.Run(mux); err != nil {
|
||||
log.Fatalf("could not run asynq server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down worker server...")
|
||||
srv.Shutdown()
|
||||
log.Println("Worker server shutdown successfully")
|
||||
}
|
||||
43
content/blog/post1.json
Normal file
43
content/blog/post1.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
43
content/blog/post2.json
Normal file
43
content/blog/post2.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
43
content/blog/post3.json
Normal file
43
content/blog/post3.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
43
content/blog/post4.json
Normal file
43
content/blog/post4.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
43
content/blog/post5.json
Normal file
43
content/blog/post5.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
2
go.mod
2
go.mod
@ -29,7 +29,6 @@ require (
|
||||
github.com/ClickHouse/ch-go v0.67.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@ -102,7 +101,6 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
go.mongodb.org/mongo-driver v1.14.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@ -30,8 +30,6 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
@ -401,8 +399,6 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg=
|
||||
|
||||
@ -534,7 +534,6 @@ type Query {
|
||||
): SearchResults!
|
||||
|
||||
trendingWorks(timePeriod: String, limit: Int): [Work!]!
|
||||
popularTranslations(workID: ID!, limit: Int): [Translation!]!
|
||||
}
|
||||
|
||||
input SearchFilters {
|
||||
|
||||
@ -10,11 +10,9 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
"tercul/internal/adapters/graphql/model"
|
||||
"tercul/internal/app/analytics"
|
||||
"tercul/internal/app/auth"
|
||||
"tercul/internal/domain"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Register is the resolver for the register field.
|
||||
@ -638,28 +636,12 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Publish analytics event
|
||||
// Increment analytics
|
||||
if comment.WorkID != nil {
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeWorkCommented,
|
||||
WorkID: comment.WorkID,
|
||||
UserID: &userID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish work commented event: %v", err)
|
||||
}
|
||||
r.App.AnalyticsService.IncrementWorkComments(ctx, *comment.WorkID)
|
||||
}
|
||||
if comment.TranslationID != nil {
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeTranslationCommented,
|
||||
TranslationID: comment.TranslationID,
|
||||
UserID: &userID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish translation commented event: %v", err)
|
||||
}
|
||||
r.App.AnalyticsService.IncrementTranslationComments(ctx, *comment.TranslationID)
|
||||
}
|
||||
|
||||
// Convert to GraphQL model
|
||||
@ -807,28 +789,12 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Publish analytics event
|
||||
// Increment analytics
|
||||
if like.WorkID != nil {
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeWorkLiked,
|
||||
WorkID: like.WorkID,
|
||||
UserID: &userID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish work liked event: %v", err)
|
||||
}
|
||||
r.App.AnalyticsService.IncrementWorkLikes(ctx, *like.WorkID)
|
||||
}
|
||||
if like.TranslationID != nil {
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeTranslationLiked,
|
||||
TranslationID: like.TranslationID,
|
||||
UserID: &userID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish translation liked event: %v", err)
|
||||
}
|
||||
r.App.AnalyticsService.IncrementTranslationLikes(ctx, *like.TranslationID)
|
||||
}
|
||||
|
||||
// Convert to GraphQL model
|
||||
@ -904,17 +870,8 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Publish analytics event
|
||||
wID := uint(workID)
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeWorkBookmarked,
|
||||
WorkID: &wID,
|
||||
UserID: &userID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish work bookmarked event: %v", err)
|
||||
}
|
||||
// Increment analytics
|
||||
r.App.AnalyticsService.IncrementWorkBookmarks(ctx, uint(workID))
|
||||
|
||||
// Convert to GraphQL model
|
||||
return &model.Bookmark{
|
||||
@ -1037,20 +994,6 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Publish analytics event for work view
|
||||
wID := uint(workID)
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeWorkViewed,
|
||||
WorkID: &wID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if userID, ok := platform_auth.GetUserIDFromContext(ctx); ok {
|
||||
event.UserID = &userID
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish work viewed event: %v", err)
|
||||
}
|
||||
|
||||
// Content resolved via Localization service
|
||||
content, err := r.App.Localization.GetWorkContent(ctx, work.ID, work.Language)
|
||||
if err != nil {
|
||||
@ -1101,40 +1044,7 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32,
|
||||
|
||||
// Translation is the resolver for the translation field.
|
||||
func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Translation, error) {
|
||||
translationID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid translation ID: %v", err)
|
||||
}
|
||||
|
||||
translation, err := r.App.TranslationRepo.GetByID(ctx, uint(translationID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if translation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Publish analytics event for translation view
|
||||
tID := uint(translationID)
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeTranslationViewed,
|
||||
TranslationID: &tID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if userID, ok := platform_auth.GetUserIDFromContext(ctx); ok {
|
||||
event.UserID = &userID
|
||||
}
|
||||
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
|
||||
log.Printf("failed to publish translation viewed event: %v", err)
|
||||
}
|
||||
|
||||
return &model.Translation{
|
||||
ID: fmt.Sprintf("%d", translation.ID),
|
||||
Name: translation.Title,
|
||||
Language: translation.Language,
|
||||
Content: &translation.Content,
|
||||
WorkID: fmt.Sprintf("%d", translation.TranslatableID),
|
||||
}, nil
|
||||
panic(fmt.Errorf("not implemented: Translation - translation"))
|
||||
}
|
||||
|
||||
// Translations is the resolver for the translations field.
|
||||
@ -1380,37 +1290,6 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32,
|
||||
panic(fmt.Errorf("not implemented: Search - search"))
|
||||
}
|
||||
|
||||
// PopularTranslations is the resolver for the popularTranslations field.
|
||||
func (r *queryResolver) PopularTranslations(ctx context.Context, workID string, limit *int) ([]*model.Translation, error) {
|
||||
wID, err := strconv.ParseUint(workID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid work ID: %v", err)
|
||||
}
|
||||
|
||||
l := 10 // default limit
|
||||
if limit != nil {
|
||||
l = *limit
|
||||
}
|
||||
|
||||
translations, err := r.App.AnalyticsService.GetPopularTranslations(ctx, uint(wID), l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*model.Translation
|
||||
for _, t := range translations {
|
||||
result = append(result, &model.Translation{
|
||||
ID: fmt.Sprintf("%d", t.ID),
|
||||
Name: t.Title,
|
||||
Language: t.Language,
|
||||
Content: &t.Content,
|
||||
WorkID: workID,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TrendingWorks is the resolver for the trendingWorks field.
|
||||
func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) {
|
||||
tp := "daily"
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
QueueAnalytics = "analytics"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeWorkViewed EventType = "work_viewed"
|
||||
EventTypeWorkLiked EventType = "work_liked"
|
||||
EventTypeWorkCommented EventType = "work_commented"
|
||||
EventTypeWorkBookmarked EventType = "work_bookmarked"
|
||||
EventTypeTranslationViewed EventType = "translation_viewed"
|
||||
EventTypeTranslationLiked EventType = "translation_liked"
|
||||
EventTypeTranslationCommented EventType = "translation_commented"
|
||||
)
|
||||
|
||||
type AnalyticsEvent struct {
|
||||
EventType EventType `json:"event_type"`
|
||||
WorkID *uint `json:"work_id,omitempty"`
|
||||
TranslationID *uint `json:"translation_id,omitempty"`
|
||||
UserID *uint `json:"user_id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
type EventPublisher interface {
|
||||
Publish(ctx context.Context, event AnalyticsEvent) error
|
||||
}
|
||||
|
||||
type AsynqClient interface {
|
||||
EnqueueContext(ctx context.Context, task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error)
|
||||
}
|
||||
|
||||
type asynqEventPublisher struct {
|
||||
client AsynqClient
|
||||
}
|
||||
|
||||
func NewEventPublisher(client AsynqClient) EventPublisher {
|
||||
return &asynqEventPublisher{client: client}
|
||||
}
|
||||
|
||||
func (p *asynqEventPublisher) Publish(ctx context.Context, event AnalyticsEvent) error {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task := asynq.NewTask(string(event.EventType), payload)
|
||||
_, err = p.client.EnqueueContext(ctx, task, asynq.Queue(QueueAnalytics))
|
||||
return err
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
package analytics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"tercul/internal/app/analytics"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type mockAsynqClient struct {
|
||||
asynq.Client
|
||||
enqueuedTasks []*asynq.Task
|
||||
}
|
||||
|
||||
func (m *mockAsynqClient) EnqueueContext(ctx context.Context, task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
|
||||
m.enqueuedTasks = append(m.enqueuedTasks, task)
|
||||
return &asynq.TaskInfo{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAsynqClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAsynqEventPublisher_Publish(t *testing.T) {
|
||||
mockClient := &mockAsynqClient{}
|
||||
publisher := analytics.NewEventPublisher(mockClient)
|
||||
|
||||
workID := uint(123)
|
||||
userID := uint(456)
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeWorkLiked,
|
||||
WorkID: &workID,
|
||||
UserID: &userID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
err := publisher.Publish(context.Background(), event)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, mockClient.enqueuedTasks, 1)
|
||||
task := mockClient.enqueuedTasks[0]
|
||||
assert.Equal(t, string(analytics.EventTypeWorkLiked), task.Type())
|
||||
|
||||
var publishedEvent analytics.AnalyticsEvent
|
||||
err = json.Unmarshal(task.Payload(), &publishedEvent)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, event.EventType, publishedEvent.EventType)
|
||||
assert.Equal(t, *event.WorkID, *publishedEvent.WorkID)
|
||||
assert.Equal(t, *event.UserID, *publishedEvent.UserID)
|
||||
}
|
||||
@ -35,8 +35,6 @@ type Service interface {
|
||||
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
|
||||
UpdateTrending(ctx context.Context) error
|
||||
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error)
|
||||
GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error)
|
||||
GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@ -257,10 +255,6 @@ func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit
|
||||
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
|
||||
}
|
||||
|
||||
func (s *service) GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error) {
|
||||
return s.repo.GetPopularTranslations(ctx, workID, limit)
|
||||
}
|
||||
|
||||
func (s *service) UpdateTrending(ctx context.Context) error {
|
||||
log.LogInfo("Updating trending works")
|
||||
|
||||
@ -305,7 +299,3 @@ func (s *service) UpdateTrending(ctx context.Context) error {
|
||||
|
||||
return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks)
|
||||
}
|
||||
|
||||
func (s *service) GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error) {
|
||||
return s.repo.GetPopularWorks(ctx, limit)
|
||||
}
|
||||
|
||||
@ -14,9 +14,8 @@ import (
|
||||
// Application is a container for all the application-layer services.
|
||||
// It's used for dependency injection into the presentation layer (e.g., GraphQL resolvers).
|
||||
type Application struct {
|
||||
AnalyticsService analytics.Service
|
||||
AnalyticsPublisher analytics.EventPublisher
|
||||
AuthCommands *auth.AuthCommands
|
||||
AnalyticsService analytics.Service
|
||||
AuthCommands *auth.AuthCommands
|
||||
AuthQueries *auth.AuthQueries
|
||||
CopyrightCommands *copyright.CopyrightCommands
|
||||
CopyrightQueries *copyright.CopyrightQueries
|
||||
|
||||
@ -148,12 +148,10 @@ func (b *ApplicationBuilder) BuildApplication() error {
|
||||
analyticsRepo := sql.NewAnalyticsRepository(b.dbConn)
|
||||
analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn)
|
||||
analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, b.linguistics.GetSentimentProvider())
|
||||
analyticsPublisher := analytics.NewEventPublisher(b.asynqClient)
|
||||
|
||||
b.App = &Application{
|
||||
AnalyticsService: analyticsService,
|
||||
AnalyticsPublisher: analyticsPublisher,
|
||||
WorkCommands: workCommands,
|
||||
AnalyticsService: analyticsService,
|
||||
WorkCommands: workCommands,
|
||||
WorkQueries: workQueries,
|
||||
AuthCommands: authCommands,
|
||||
AuthQueries: authQueries,
|
||||
|
||||
@ -56,46 +56,6 @@ func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID u
|
||||
})
|
||||
}
|
||||
|
||||
func (r *analyticsRepository) GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error) {
|
||||
var popularWorks []*domain.PopularWork
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&domain.WorkStats{}).
|
||||
Select("work_id, (views + likes*2 + comments*3 + bookmarks*4) as score").
|
||||
Order("score desc").
|
||||
Limit(limit).
|
||||
Find(&popularWorks).Error
|
||||
return popularWorks, err
|
||||
}
|
||||
|
||||
func (r *analyticsRepository) GetWorkViews(ctx context.Context, workID uint) (int, error) {
|
||||
var stats domain.WorkStats
|
||||
err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&stats).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return int(stats.Views), nil
|
||||
}
|
||||
|
||||
func (r *analyticsRepository) GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error) {
|
||||
var translations []*domain.Translation
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Joins("LEFT JOIN translation_stats ON translation_stats.translation_id = translations.id").
|
||||
Where("translations.translatable_id = ? AND translations.translatable_type = ?", workID, "Work").
|
||||
Order("translation_stats.views + (translation_stats.likes * 2) DESC").
|
||||
Limit(limit).
|
||||
Find(&translations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
|
||||
var trendingWorks []*domain.Trending
|
||||
err := r.db.WithContext(ctx).
|
||||
|
||||
@ -15,12 +15,4 @@ type AnalyticsRepository interface {
|
||||
UpdateUserEngagement(ctx context.Context, userEngagement *UserEngagement) error
|
||||
UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*Trending) error
|
||||
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*Work, error)
|
||||
GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*Translation, error)
|
||||
GetPopularWorks(ctx context.Context, limit int) ([]*PopularWork, error)
|
||||
GetWorkViews(ctx context.Context, workID uint) (int, error)
|
||||
}
|
||||
|
||||
type PopularWork struct {
|
||||
WorkID uint
|
||||
Score float64
|
||||
}
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"tercul/internal/app/analytics"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
analyticsService analytics.Service
|
||||
}
|
||||
|
||||
func NewWorker(analyticsService analytics.Service) *Worker {
|
||||
return &Worker{analyticsService: analyticsService}
|
||||
}
|
||||
|
||||
func (w *Worker) ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||
var event analytics.AnalyticsEvent
|
||||
if err := json.Unmarshal(t.Payload(), &event); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal analytics event: %w", err)
|
||||
}
|
||||
|
||||
switch event.EventType {
|
||||
case analytics.EventTypeWorkViewed:
|
||||
if event.WorkID != nil {
|
||||
return w.analyticsService.IncrementWorkViews(ctx, *event.WorkID)
|
||||
}
|
||||
case analytics.EventTypeWorkLiked:
|
||||
if event.WorkID != nil {
|
||||
return w.analyticsService.IncrementWorkLikes(ctx, *event.WorkID)
|
||||
}
|
||||
case analytics.EventTypeWorkCommented:
|
||||
if event.WorkID != nil {
|
||||
return w.analyticsService.IncrementWorkComments(ctx, *event.WorkID)
|
||||
}
|
||||
case analytics.EventTypeWorkBookmarked:
|
||||
if event.WorkID != nil {
|
||||
return w.analyticsService.IncrementWorkBookmarks(ctx, *event.WorkID)
|
||||
}
|
||||
case analytics.EventTypeTranslationViewed:
|
||||
if event.TranslationID != nil {
|
||||
return w.analyticsService.IncrementTranslationViews(ctx, *event.TranslationID)
|
||||
}
|
||||
case analytics.EventTypeTranslationLiked:
|
||||
if event.TranslationID != nil {
|
||||
return w.analyticsService.IncrementTranslationLikes(ctx, *event.TranslationID)
|
||||
}
|
||||
case analytics.EventTypeTranslationCommented:
|
||||
if event.TranslationID != nil {
|
||||
return w.analyticsService.IncrementTranslationComments(ctx, *event.TranslationID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown analytics event type: %s", event.EventType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package analytics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"tercul/internal/app/analytics"
|
||||
analyticsjob "tercul/internal/jobs/analytics"
|
||||
"tercul/internal/testutil"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type WorkerIntegrationTestSuite struct {
|
||||
testutil.IntegrationTestSuite
|
||||
}
|
||||
|
||||
func TestWorkerIntegrationTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(WorkerIntegrationTestSuite))
|
||||
}
|
||||
|
||||
func (s *WorkerIntegrationTestSuite) SetupTest() {
|
||||
s.IntegrationTestSuite.SetupSuite(nil)
|
||||
s.IntegrationTestSuite.SetupTest()
|
||||
}
|
||||
|
||||
func (s *WorkerIntegrationTestSuite) TestWorker_ProcessTask() {
|
||||
// Create a new worker
|
||||
worker := analyticsjob.NewWorker(s.App.AnalyticsService)
|
||||
|
||||
// Create a new asynq client
|
||||
redisAddr := s.Config.RedisAddr
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
|
||||
defer client.Close()
|
||||
|
||||
// Create a new asynq server and register the handler
|
||||
srv := asynq.NewServer(
|
||||
asynq.RedisClientOpt{Addr: redisAddr},
|
||||
asynq.Config{
|
||||
Concurrency: 1,
|
||||
Queues: map[string]int{
|
||||
"analytics": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
mux := asynq.NewServeMux()
|
||||
mux.HandleFunc("analytics:event", worker.ProcessTask)
|
||||
|
||||
// Enqueue a task
|
||||
work := testutil.CreateWork(s.Ctx, s.DB, "Test Work", "Test Author")
|
||||
event := analytics.AnalyticsEvent{
|
||||
EventType: analytics.EventTypeWorkViewed,
|
||||
WorkID: &work.ID,
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
s.Require().NoError(err)
|
||||
|
||||
task := asynq.NewTask("analytics:event", payload)
|
||||
_, err = client.Enqueue(task, asynq.Queue("analytics"))
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Process the task
|
||||
go func() {
|
||||
err := srv.Run(mux)
|
||||
s.Require().NoError(err)
|
||||
}()
|
||||
defer srv.Stop()
|
||||
|
||||
// Verify
|
||||
s.Eventually(func() bool {
|
||||
popular, err := s.App.AnalyticsService.GetPopularWorks(context.Background(), 10)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, p := range popular {
|
||||
if p.WorkID == work.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond, "work should be in popular list")
|
||||
}
|
||||
@ -12,8 +12,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/hibiken/asynq"
|
||||
graph "tercul/internal/adapters/graphql"
|
||||
"tercul/internal/app/auth"
|
||||
auth_platform "tercul/internal/platform/auth"
|
||||
@ -34,9 +32,6 @@ type IntegrationTestSuite struct {
|
||||
suite.Suite
|
||||
App *app.Application
|
||||
DB *gorm.DB
|
||||
AsynqClient *asynq.Client
|
||||
Config *TestConfig
|
||||
miniRedis *miniredis.Miniredis
|
||||
WorkRepo domain.WorkRepository
|
||||
UserRepo domain.UserRepository
|
||||
AuthorRepo domain.AuthorRepository
|
||||
@ -61,7 +56,6 @@ type IntegrationTestSuite struct {
|
||||
AuthCommands *auth.AuthCommands
|
||||
AuthQueries *auth.AuthQueries
|
||||
AnalyticsService analytics.Service
|
||||
Ctx context.Context
|
||||
|
||||
// Test data
|
||||
TestWorks []*domain.Work
|
||||
@ -75,20 +69,14 @@ type TestConfig struct {
|
||||
UseInMemoryDB bool // If true, use SQLite in-memory, otherwise use mock repositories
|
||||
DBPath string // Path for SQLite file (only used if UseInMemoryDB is false)
|
||||
LogLevel logger.LogLevel
|
||||
RedisAddr string
|
||||
}
|
||||
|
||||
// DefaultTestConfig returns a default test configuration
|
||||
func DefaultTestConfig() *TestConfig {
|
||||
redisAddr := os.Getenv("REDIS_ADDR")
|
||||
if redisAddr == "" {
|
||||
redisAddr = "localhost:6379"
|
||||
}
|
||||
return &TestConfig{
|
||||
UseInMemoryDB: true,
|
||||
DBPath: "",
|
||||
LogLevel: logger.Silent,
|
||||
RedisAddr: redisAddr,
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,7 +85,6 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
|
||||
if config == nil {
|
||||
config = DefaultTestConfig()
|
||||
}
|
||||
s.Config = config
|
||||
|
||||
if config.UseInMemoryDB {
|
||||
s.setupInMemoryDB(config)
|
||||
@ -105,17 +92,6 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
|
||||
s.setupMockRepositories()
|
||||
}
|
||||
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
s.T().Fatalf("an error '%s' was not expected when starting miniredis", err)
|
||||
}
|
||||
s.miniRedis = mr
|
||||
config.RedisAddr = mr.Addr()
|
||||
|
||||
s.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: config.RedisAddr,
|
||||
})
|
||||
|
||||
s.setupServices()
|
||||
s.setupTestData()
|
||||
}
|
||||
@ -263,10 +239,8 @@ func (s *IntegrationTestSuite) setupServices() {
|
||||
monetizationCommands := monetization.NewMonetizationCommands(s.MonetizationRepo)
|
||||
monetizationQueries := monetization.NewMonetizationQueries(s.MonetizationRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo)
|
||||
|
||||
analyticsPublisher := analytics.NewEventPublisher(s.AsynqClient)
|
||||
s.App = &app.Application{
|
||||
AnalyticsService: s.AnalyticsService,
|
||||
AnalyticsPublisher: analyticsPublisher,
|
||||
WorkCommands: s.WorkCommands,
|
||||
WorkQueries: s.WorkQueries,
|
||||
AuthCommands: s.AuthCommands,
|
||||
@ -370,9 +344,6 @@ func (s *IntegrationTestSuite) setupTestData() {
|
||||
|
||||
// TearDownSuite cleans up the test suite
|
||||
func (s *IntegrationTestSuite) TearDownSuite() {
|
||||
if s.miniRedis != nil {
|
||||
s.miniRedis.Close()
|
||||
}
|
||||
if s.DB != nil {
|
||||
sqlDB, err := s.DB.DB()
|
||||
if err == nil {
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
"tercul/internal/domain"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
@ -158,18 +156,3 @@ func SkipIfShort(t *testing.T) {
|
||||
t.Skip("Skipping test in short mode")
|
||||
}
|
||||
}
|
||||
|
||||
func CreateWork(ctx context.Context, db *gorm.DB, title, authorName string) *domain.Work {
|
||||
author := &domain.Author{Name: authorName}
|
||||
db.Create(author)
|
||||
|
||||
work := &domain.Work{
|
||||
Title: title,
|
||||
Authors: []*domain.Author{author},
|
||||
TranslatableModel: domain.TranslatableModel{
|
||||
Language: "en",
|
||||
},
|
||||
}
|
||||
db.Create(work)
|
||||
return work
|
||||
}
|
||||
|
||||
41
schemas/_defs.json
Normal file
41
schemas/_defs.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.com/schemas/_defs.json",
|
||||
"title": "Common Definitions",
|
||||
"$defs": {
|
||||
"imageAsset": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url", "alt"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"alt": { "type": "string" },
|
||||
"width": { "type": "integer", "minimum": 1 },
|
||||
"height": { "type": "integer", "minimum": 1 },
|
||||
"mime": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "url"],
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"mime": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "url"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"publisher": { "type": "string" },
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"date_accessed": { "type": "string", "format": "date" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
239
schemas/blog.json
Normal file
239
schemas/blog.json
Normal file
@ -0,0 +1,239 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.com/schemas/blog.json",
|
||||
"title": "Blog Post",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"contentTypeSlug",
|
||||
"title",
|
||||
"slug",
|
||||
"status",
|
||||
"content",
|
||||
"languageCode",
|
||||
"isDefault"
|
||||
],
|
||||
"properties": {
|
||||
"contentTypeSlug": {
|
||||
"type": "string",
|
||||
"enum": ["blog"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||
"minLength": 3,
|
||||
"maxLength": 200
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["planned", "draft", "scheduled", "published", "archived"]
|
||||
},
|
||||
"content": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"excerpt",
|
||||
"content",
|
||||
"publishDate",
|
||||
"author",
|
||||
"tags",
|
||||
"meta_title",
|
||||
"meta_description"
|
||||
],
|
||||
"properties": {
|
||||
"excerpt": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"publishDate": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": false
|
||||
},
|
||||
"meta_title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"meta_description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"languageCode": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z]{2}(?:-[A-Z]{2})?$"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{3,200}$"
|
||||
},
|
||||
"translation_group_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{3,200}$"
|
||||
},
|
||||
"lifecycle": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["planned", "draft", "scheduled", "published", "archived"]
|
||||
},
|
||||
"scheduled_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"canonical": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"og_title": {
|
||||
"type": "string"
|
||||
},
|
||||
"og_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"og_image": {
|
||||
"$ref": "_defs.json#/$defs/imageAsset"
|
||||
},
|
||||
"twitter_card": {
|
||||
"type": "string",
|
||||
"enum": ["summary", "summary_large_image"]
|
||||
},
|
||||
"json_ld": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"taxonomy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"series": {
|
||||
"type": "string"
|
||||
},
|
||||
"featured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"pin_until": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"related_services": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"related_posts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "_defs.json#/$defs/source"
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"hero_image": {
|
||||
"$ref": "_defs.json#/$defs/imageAsset"
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "_defs.json#/$defs/attachment" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" },
|
||||
"created_by": { "type": "string" },
|
||||
"updated_by": { "type": "string" },
|
||||
"revision": { "type": "integer", "minimum": 0 },
|
||||
"fact_checked": { "type": "boolean" },
|
||||
"legal_reviewed": { "type": "boolean" },
|
||||
"change_notes": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"views": { "type": "integer", "minimum": 0 },
|
||||
"avg_read_time_sec": { "type": "integer", "minimum": 0 },
|
||||
"cta_clicks": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"readability": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reading_time_minutes_est": { "type": "integer", "minimum": 0 },
|
||||
"word_count": { "type": "integer", "minimum": 0 },
|
||||
"summary_bullets": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"maxItems": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
validate.py
Normal file
45
validate.py
Normal file
@ -0,0 +1,45 @@
|
||||
import json
|
||||
import os
|
||||
from jsonschema import validate
|
||||
from referencing import Registry, Resource
|
||||
from referencing.jsonschema import DRAFT202012
|
||||
|
||||
def main():
|
||||
"""
|
||||
Validates the example blog posts against the blog.json schema.
|
||||
"""
|
||||
schemas_dir = "schemas"
|
||||
content_dir = "content/blog"
|
||||
|
||||
# Create a resource for each schema
|
||||
blog_schema_path = os.path.join(schemas_dir, "blog.json")
|
||||
with open(blog_schema_path, "r") as f:
|
||||
blog_schema_resource = Resource.from_contents(json.load(f), default_specification=DRAFT202012)
|
||||
|
||||
defs_schema_path = os.path.join(schemas_dir, "_defs.json")
|
||||
with open(defs_schema_path, "r") as f:
|
||||
defs_schema_resource = Resource.from_contents(json.load(f), default_specification=DRAFT202012)
|
||||
|
||||
# Create a registry and add the resources
|
||||
registry = Registry().with_resources(
|
||||
[
|
||||
("blog.json", blog_schema_resource),
|
||||
("_defs.json", defs_schema_resource),
|
||||
]
|
||||
)
|
||||
|
||||
# Validate each blog post
|
||||
for filename in os.listdir(content_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(content_dir, filename)
|
||||
with open(filepath, "r") as f:
|
||||
instance = json.load(f)
|
||||
|
||||
try:
|
||||
validate(instance=instance, schema=blog_schema_resource.contents, registry=registry)
|
||||
print(f"Successfully validated {filename}")
|
||||
except Exception as e:
|
||||
print(f"Validation failed for {filename}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user