Chore: Clean up lint warnings and improve code quality

This commit addresses numerous linting errors and improves overall code quality.

- Fixed dozens of 'errcheck' violations by adding error handling and logging for ignored errors, particularly in analytics goroutines and test setup.
- Resolved 'ineffassign' and 'staticcheck' warnings by refactoring variable scopes and suppressing intentional-but-flagged test patterns.
- Removed dead code identified by the 'unused' linter, including helper functions and mock services.
- Refactored test suites to fix inheritance issues, consolidating GraphQL integration tests and correcting test setup logic.
- Corrected invalid logging calls that were causing type check failures.

The codebase now passes 'make lint-test' cleanly.
This commit is contained in:
google-labs-jules[bot] 2025-10-07 13:14:01 +00:00
parent fa90dd79da
commit 777f6fa965
18 changed files with 400 additions and 368 deletions

View File

@ -2,6 +2,6 @@
lint-test:
@echo "Running linter..."
golangci-lint run
golangci-lint run --timeout=5m
@echo "Running tests..."
go test ./...

View File

@ -1,241 +0,0 @@
package graphql_test
import (
"tercul/internal/adapters/graphql/model"
"tercul/internal/domain"
)
type CreateBookResponse struct {
CreateBook model.Book `json:"createBook"`
}
type GetBookResponse struct {
Book model.Book `json:"book"`
}
type GetBooksResponse struct {
Books []model.Book `json:"books"`
}
type UpdateBookResponse struct {
UpdateBook model.Book `json:"updateBook"`
}
func (s *GraphQLIntegrationSuite) TestBookMutations() {
// Create users for testing authorization
_, readerToken := s.CreateAuthenticatedUser("bookreader", "bookreader@test.com", domain.UserRoleReader)
_, adminToken := s.CreateAuthenticatedUser("bookadmin", "bookadmin@test.com", domain.UserRoleAdmin)
var bookID string
s.Run("a reader can create a book", func() {
// Define the mutation
mutation := `
mutation CreateBook($input: BookInput!) {
createBook(input: $input) {
id
name
description
language
isbn
}
}
`
// Define the variables
variables := map[string]interface{}{
"input": map[string]interface{}{
"name": "My New Book",
"description": "A book about something.",
"language": "en",
"isbn": "978-3-16-148410-0",
},
}
// Execute the mutation
response, err := executeGraphQL[CreateBookResponse](s, mutation, variables, &readerToken)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
// Verify the response
s.NotNil(response.Data.CreateBook.ID, "Book ID should not be nil")
bookID = response.Data.CreateBook.ID
s.Equal("My New Book", response.Data.CreateBook.Name)
s.Equal("A book about something.", *response.Data.CreateBook.Description)
s.Equal("en", response.Data.CreateBook.Language)
s.Equal("978-3-16-148410-0", *response.Data.CreateBook.Isbn)
})
s.Run("a reader is forbidden from updating a book", func() {
// Define the mutation
mutation := `
mutation UpdateBook($id: ID!, $input: BookInput!) {
updateBook(id: $id, input: $input) {
id
}
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
"input": map[string]interface{}{
"name": "Updated Book Name",
"language": "en",
},
}
// Execute the mutation with the reader's token
response, err := executeGraphQL[any](s, mutation, variables, &readerToken)
s.Require().NoError(err)
s.Require().NotNil(response.Errors)
})
s.Run("an admin can update a book", func() {
// Define the mutation
mutation := `
mutation UpdateBook($id: ID!, $input: BookInput!) {
updateBook(id: $id, input: $input) {
id
name
}
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
"input": map[string]interface{}{
"name": "Updated Book Name by Admin",
"language": "en",
},
}
// Execute the mutation with the admin's token
response, err := executeGraphQL[UpdateBookResponse](s, mutation, variables, &adminToken)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
})
s.Run("a reader is forbidden from deleting a book", func() {
// Define the mutation
mutation := `
mutation DeleteBook($id: ID!) {
deleteBook(id: $id)
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
}
// Execute the mutation with the reader's token
response, err := executeGraphQL[any](s, mutation, variables, &readerToken)
s.Require().NoError(err)
s.Require().NotNil(response.Errors)
})
s.Run("an admin can delete a book", func() {
// Define the mutation
mutation := `
mutation DeleteBook($id: ID!) {
deleteBook(id: $id)
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
}
// Execute the mutation with the admin's token
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
s.Require().NoError(err)
s.Require().Nil(response.Errors)
s.True(response.Data.(map[string]interface{})["deleteBook"].(bool))
})
}
func (s *GraphQLIntegrationSuite) TestBookQueries() {
// Create a book to query
_, adminToken := s.CreateAuthenticatedUser("bookadmin2", "bookadmin2@test.com", domain.UserRoleAdmin)
createMutation := `
mutation CreateBook($input: BookInput!) {
createBook(input: $input) {
id
}
}
`
createVariables := map[string]interface{}{
"input": map[string]interface{}{
"name": "Queryable Book",
"description": "A book to be queried.",
"language": "en",
"isbn": "978-0-306-40615-7",
},
}
createResponse, err := executeGraphQL[CreateBookResponse](s, createMutation, createVariables, &adminToken)
s.Require().NoError(err)
bookID := createResponse.Data.CreateBook.ID
s.Run("should get a book by ID", func() {
// Define the query
query := `
query GetBook($id: ID!) {
book(id: $id) {
id
name
description
}
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
}
// Execute the query
response, err := executeGraphQL[GetBookResponse](s, query, variables, nil)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL query should not return errors")
// Verify the response
s.Equal(bookID, response.Data.Book.ID)
s.Equal("Queryable Book", response.Data.Book.Name)
s.Equal("A book to be queried.", *response.Data.Book.Description)
})
s.Run("should get a list of books", func() {
// Define the query
query := `
query GetBooks {
books {
id
name
}
}
`
// Execute the query
response, err := executeGraphQL[GetBooksResponse](s, query, nil, nil)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL query should not return errors")
// Verify the response
s.True(len(response.Data.Books) >= 1)
foundBook := false
for _, book := range response.Data.Books {
if book.ID == bookID {
foundBook = true
break
}
}
s.True(foundBook, "The created book should be in the list")
})
}

View File

@ -9,6 +9,7 @@ import (
"testing"
graph "tercul/internal/adapters/graphql"
"tercul/internal/adapters/graphql/model"
"tercul/internal/app/auth"
"tercul/internal/app/author"
"tercul/internal/app/bookmark"
@ -645,6 +646,241 @@ func TestGraphQLIntegrationSuite(t *testing.T) {
suite.Run(t, new(GraphQLIntegrationSuite))
}
func (s *GraphQLIntegrationSuite) TestBookMutations() {
// Create users for testing authorization
_, readerToken := s.CreateAuthenticatedUser("bookreader", "bookreader@test.com", domain.UserRoleReader)
_, adminToken := s.CreateAuthenticatedUser("bookadmin", "bookadmin@test.com", domain.UserRoleAdmin)
var bookID string
s.Run("a reader can create a book", func() {
// Define the mutation
mutation := `
mutation CreateBook($input: BookInput!) {
createBook(input: $input) {
id
name
description
language
isbn
}
}
`
// Define the variables
variables := map[string]interface{}{
"input": map[string]interface{}{
"name": "My New Book",
"description": "A book about something.",
"language": "en",
"isbn": "978-3-16-148410-0",
},
}
// Execute the mutation
response, err := executeGraphQL[CreateBookResponse](s, mutation, variables, &readerToken)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
// Verify the response
s.NotNil(response.Data.CreateBook.ID, "Book ID should not be nil")
bookID = response.Data.CreateBook.ID
s.Equal("My New Book", response.Data.CreateBook.Name)
s.Equal("A book about something.", *response.Data.CreateBook.Description)
s.Equal("en", response.Data.CreateBook.Language)
s.Equal("978-3-16-148410-0", *response.Data.CreateBook.Isbn)
})
s.Run("a reader is forbidden from updating a book", func() {
// Define the mutation
mutation := `
mutation UpdateBook($id: ID!, $input: BookInput!) {
updateBook(id: $id, input: $input) {
id
}
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
"input": map[string]interface{}{
"name": "Updated Book Name",
"language": "en",
},
}
// Execute the mutation with the reader's token
response, err := executeGraphQL[any](s, mutation, variables, &readerToken)
s.Require().NoError(err)
s.Require().NotNil(response.Errors)
})
s.Run("an admin can update a book", func() {
// Define the mutation
mutation := `
mutation UpdateBook($id: ID!, $input: BookInput!) {
updateBook(id: $id, input: $input) {
id
name
}
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
"input": map[string]interface{}{
"name": "Updated Book Name by Admin",
"language": "en",
},
}
// Execute the mutation with the admin's token
response, err := executeGraphQL[UpdateBookResponse](s, mutation, variables, &adminToken)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
})
s.Run("a reader is forbidden from deleting a book", func() {
// Define the mutation
mutation := `
mutation DeleteBook($id: ID!) {
deleteBook(id: $id)
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
}
// Execute the mutation with the reader's token
response, err := executeGraphQL[any](s, mutation, variables, &readerToken)
s.Require().NoError(err)
s.Require().NotNil(response.Errors)
})
s.Run("an admin can delete a book", func() {
// Define the mutation
mutation := `
mutation DeleteBook($id: ID!) {
deleteBook(id: $id)
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
}
// Execute the mutation with the admin's token
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
s.Require().NoError(err)
s.Require().Nil(response.Errors)
s.True(response.Data.(map[string]interface{})["deleteBook"].(bool))
})
}
func (s *GraphQLIntegrationSuite) TestBookQueries() {
// Create a book to query
_, adminToken := s.CreateAuthenticatedUser("bookadmin2", "bookadmin2@test.com", domain.UserRoleAdmin)
createMutation := `
mutation CreateBook($input: BookInput!) {
createBook(input: $input) {
id
}
}
`
createVariables := map[string]interface{}{
"input": map[string]interface{}{
"name": "Queryable Book",
"description": "A book to be queried.",
"language": "en",
"isbn": "978-0-306-40615-7",
},
}
createResponse, err := executeGraphQL[CreateBookResponse](s, createMutation, createVariables, &adminToken)
s.Require().NoError(err)
bookID := createResponse.Data.CreateBook.ID
s.Run("should get a book by ID", func() {
// Define the query
query := `
query GetBook($id: ID!) {
book(id: $id) {
id
name
description
}
}
`
// Define the variables
variables := map[string]interface{}{
"id": bookID,
}
// Execute the query
response, err := executeGraphQL[GetBookResponse](s, query, variables, nil)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL query should not return errors")
// Verify the response
s.Equal(bookID, response.Data.Book.ID)
s.Equal("Queryable Book", response.Data.Book.Name)
s.Equal("A book to be queried.", *response.Data.Book.Description)
})
s.Run("should get a list of books", func() {
// Define the query
query := `
query GetBooks {
books {
id
name
}
}
`
// Execute the query
response, err := executeGraphQL[GetBooksResponse](s, query, nil, nil)
s.Require().NoError(err)
s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL query should not return errors")
// Verify the response
s.True(len(response.Data.Books) >= 1)
foundBook := false
for _, book := range response.Data.Books {
if book.ID == bookID {
foundBook = true
break
}
}
s.True(foundBook, "The created book should be in the list")
})
}
type CreateBookResponse struct {
CreateBook model.Book `json:"createBook"`
}
type GetBookResponse struct {
Book model.Book `json:"book"`
}
type GetBooksResponse struct {
Books []model.Book `json:"books"`
}
type UpdateBookResponse struct {
UpdateBook model.Book `json:"updateBook"`
}
type CreateCollectionResponse struct {
CreateCollection struct {
ID string `json:"id"`
@ -928,7 +1164,8 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
// Cleanup
bookmarkID, err := strconv.ParseUint(bookmarkData["id"].(string), 10, 32)
s.Require().NoError(err)
s.App.Bookmark.Commands.DeleteBookmark(context.Background(), uint(bookmarkID))
err = s.App.Bookmark.Commands.DeleteBookmark(context.Background(), uint(bookmarkID))
s.Require().NoError(err)
})
s.Run("should not delete a bookmark owned by another user", func() {
@ -939,7 +1176,10 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
Name: "A Bookmark",
})
s.Require().NoError(err)
s.T().Cleanup(func() { s.App.Bookmark.Commands.DeleteBookmark(context.Background(), createdBookmark.ID) })
s.T().Cleanup(func() {
err := s.App.Bookmark.Commands.DeleteBookmark(context.Background(), createdBookmark.ID)
s.Require().NoError(err)
})
// Define the mutation
mutation := `

View File

@ -18,6 +18,7 @@ import (
"tercul/internal/app/user"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"tercul/internal/platform/log"
"time"
)
@ -205,7 +206,11 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr
return nil, err
}
go r.App.Analytics.IncrementWorkTranslationCount(context.Background(), uint(workID))
go func() {
if err := r.App.Analytics.IncrementWorkTranslationCount(context.Background(), uint(workID)); err != nil {
log.Error(err, "failed to increment work translation count")
}
}()
return &model.Translation{
ID: fmt.Sprintf("%d", createdTranslation.ID),
@ -700,10 +705,14 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
}
if createdComment.WorkID != nil {
r.App.Analytics.IncrementWorkComments(ctx, *createdComment.WorkID)
if err := r.App.Analytics.IncrementWorkComments(ctx, *createdComment.WorkID); err != nil {
log.FromContext(ctx).Error(err, "failed to increment work comments")
}
}
if createdComment.TranslationID != nil {
r.App.Analytics.IncrementTranslationComments(ctx, *createdComment.TranslationID)
if err := r.App.Analytics.IncrementTranslationComments(ctx, *createdComment.TranslationID); err != nil {
log.FromContext(ctx).Error(err, "failed to increment translation comments")
}
}
return &model.Comment{
@ -837,10 +846,14 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
}
if createdLike.WorkID != nil {
r.App.Analytics.IncrementWorkLikes(ctx, *createdLike.WorkID)
if err := r.App.Analytics.IncrementWorkLikes(ctx, *createdLike.WorkID); err != nil {
log.FromContext(ctx).Error(err, "failed to increment work likes")
}
}
if createdLike.TranslationID != nil {
r.App.Analytics.IncrementTranslationLikes(ctx, *createdLike.TranslationID)
if err := r.App.Analytics.IncrementTranslationLikes(ctx, *createdLike.TranslationID); err != nil {
log.FromContext(ctx).Error(err, "failed to increment translation likes")
}
}
return &model.Like{
@ -906,7 +919,9 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm
return nil, err
}
r.App.Analytics.IncrementWorkBookmarks(ctx, uint(workID))
if err := r.App.Analytics.IncrementWorkBookmarks(ctx, uint(workID)); err != nil {
log.FromContext(ctx).Error(err, "failed to increment work bookmarks")
}
return &model.Bookmark{
ID: fmt.Sprintf("%d", createdBookmark.ID),
@ -1253,7 +1268,11 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error
return nil, nil
}
go r.App.Analytics.IncrementWorkViews(context.Background(), uint(workID))
go func() {
if err := r.App.Analytics.IncrementWorkViews(context.Background(), uint(workID)); err != nil {
log.Error(err, "failed to increment work views")
}
}()
content := r.resolveWorkContent(ctx, workDTO.ID, workDTO.Language)
@ -1309,7 +1328,11 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran
return nil, nil
}
go r.App.Analytics.IncrementTranslationViews(context.Background(), uint(translationID))
go func() {
if err := r.App.Analytics.IncrementTranslationViews(context.Background(), uint(translationID)); err != nil {
log.Error(err, "failed to increment translation views")
}
}()
return &model.Translation{
ID: id,
@ -1741,8 +1764,8 @@ func (r *queryResolver) Collections(ctx context.Context, userID *string, limit *
var err error
if userID != nil {
uID, err := strconv.ParseUint(*userID, 10, 32)
if err != nil {
uID, idErr := strconv.ParseUint(*userID, 10, 32)
if idErr != nil {
return nil, fmt.Errorf("%w: invalid user ID", domain.ErrValidation)
}
collectionRecords, err = r.App.Collection.Queries.CollectionsByUserID(ctx, uint(uID))
@ -1889,20 +1912,20 @@ func (r *queryResolver) Comments(ctx context.Context, workID *string, translatio
var err error
if workID != nil {
wID, err := strconv.ParseUint(*workID, 10, 32)
if err != nil {
wID, idErr := strconv.ParseUint(*workID, 10, 32)
if idErr != nil {
return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
}
commentRecords, err = r.App.Comment.Queries.CommentsByWorkID(ctx, uint(wID))
} else if translationID != nil {
tID, err := strconv.ParseUint(*translationID, 10, 32)
if err != nil {
tID, idErr := strconv.ParseUint(*translationID, 10, 32)
if idErr != nil {
return nil, fmt.Errorf("%w: invalid translation ID", domain.ErrValidation)
}
commentRecords, err = r.App.Comment.Queries.CommentsByTranslationID(ctx, uint(tID))
} else if userID != nil {
uID, err := strconv.ParseUint(*userID, 10, 32)
if err != nil {
uID, idErr := strconv.ParseUint(*userID, 10, 32)
if idErr != nil {
return nil, fmt.Errorf("%w: invalid user ID", domain.ErrValidation)
}
commentRecords, err = r.App.Comment.Queries.CommentsByUserID(ctx, uint(uID))

View File

@ -14,23 +14,33 @@ import (
"github.com/stretchr/testify/suite"
)
// AnalyticsServiceTestSuite is a test suite for the analytics service.
// It embeds the IntegrationTestSuite to get access to the database, app, etc.
type AnalyticsServiceTestSuite struct {
testutil.IntegrationTestSuite
service analytics.Service
}
// SetupSuite sets up the test suite with a real database and a real analytics service.
func (s *AnalyticsServiceTestSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
// Call the parent suite's setup
s.IntegrationTestSuite.SetupSuite(nil)
// Create a real analytics service with the test database
cfg, err := config.LoadConfig()
s.Require().NoError(err)
analyticsRepo := sql.NewAnalyticsRepository(s.DB, cfg)
analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB)
translationRepo := sql.NewTranslationRepository(s.DB, cfg)
workRepo := sql.NewWorkRepository(s.DB, cfg)
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
sentimentProvider, err := linguistics.NewGoVADERSentimentProvider()
s.Require().NoError(err)
// Create the service to be tested
s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, sentimentProvider)
}
// SetupTest cleans the database before each test.
func (s *AnalyticsServiceTestSuite) SetupTest() {
s.IntegrationTestSuite.SetupTest()
s.DB.Exec("DELETE FROM trendings")
@ -258,6 +268,7 @@ func (s *AnalyticsServiceTestSuite) TestUpdateTrending() {
})
}
// TestAnalyticsService runs the full test suite.
func TestAnalyticsService(t *testing.T) {
suite.Run(t, new(AnalyticsServiceTestSuite))
}

View File

@ -33,7 +33,7 @@ func (s *AuthCommandsSuite) TestLogin_Success() {
Password: "password",
Active: true,
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
input := LoginInput{Email: "test@example.com", Password: "password"}
resp, err := s.commands.Login(context.Background(), input)
@ -87,7 +87,7 @@ func (s *AuthCommandsSuite) TestLogin_SuccessUpdate() {
Password: "password",
Active: true,
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
s.userRepo.updateFunc = func(ctx context.Context, user *domain.User) error {
return nil
}
@ -118,7 +118,7 @@ func (s *AuthCommandsSuite) TestLogin_UpdateUserError() {
Password: "password",
Active: true,
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
s.userRepo.updateFunc = func(ctx context.Context, user *domain.User) error {
return errors.New("update error")
}
@ -156,7 +156,7 @@ func (s *AuthCommandsSuite) TestLogin_InactiveUser() {
Password: "password",
Active: false,
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
input := LoginInput{Email: "inactive@example.com", Password: "password"}
resp, err := s.commands.Login(context.Background(), input)
@ -170,7 +170,7 @@ func (s *AuthCommandsSuite) TestLogin_InvalidPassword() {
Password: "password",
Active: true,
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
input := LoginInput{Email: "test@example.com", Password: "wrong-password"}
resp, err := s.commands.Login(context.Background(), input)
@ -184,7 +184,7 @@ func (s *AuthCommandsSuite) TestLogin_TokenGenerationError() {
Password: "password",
Active: true,
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
s.jwtManager.generateTokenFunc = func(user *domain.User) (string, error) {
return "", errors.New("jwt error")
@ -221,7 +221,7 @@ func (s *AuthCommandsSuite) TestRegister_EmailExists() {
user := domain.User{
Email: "exists@example.com",
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
input := RegisterInput{
Username: "newuser",
@ -239,7 +239,7 @@ func (s *AuthCommandsSuite) TestRegister_UsernameExists() {
user := domain.User{
Username: "exists",
}
s.userRepo.Create(context.Background(), &user)
assert.NoError(s.T(), s.userRepo.Create(context.Background(), &user))
input := RegisterInput{
Username: "exists",

View File

@ -67,12 +67,14 @@ func (s *AuthQueriesSuite) TestGetUserFromContext_InactiveUser() {
}
func (s *AuthQueriesSuite) TestGetUserFromContext_NilContext() {
//nolint:staticcheck // This test intentionally passes a nil context to verify error handling.
user, err := s.queries.GetUserFromContext(nil)
assert.ErrorIs(s.T(), err, ErrContextRequired)
assert.Nil(s.T(), user)
}
func (s *AuthQueriesSuite) TestValidateToken_NilContext() {
//nolint:staticcheck // This test intentionally passes a nil context to verify error handling.
user, err := s.queries.ValidateToken(nil, "token")
assert.ErrorIs(s.T(), err, ErrContextRequired)
assert.Nil(s.T(), user)

View File

@ -4,6 +4,7 @@ import (
"context"
"tercul/internal/app/analytics"
"tercul/internal/domain"
"tercul/internal/platform/log"
)
// BookmarkCommands contains the command handlers for the bookmark aggregate.
@ -42,7 +43,11 @@ func (c *BookmarkCommands) CreateBookmark(ctx context.Context, input CreateBookm
}
if c.analyticsSvc != nil {
go c.analyticsSvc.IncrementWorkBookmarks(context.Background(), input.WorkID)
go func() {
if err := c.analyticsSvc.IncrementWorkBookmarks(context.Background(), input.WorkID); err != nil {
log.Error(err, "failed to increment work bookmarks")
}
}()
}
return bookmark, nil

View File

@ -8,6 +8,7 @@ import (
"tercul/internal/app/authz"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"tercul/internal/platform/log"
)
// CommentCommands contains the command handlers for the comment aggregate.
@ -51,10 +52,18 @@ func (c *CommentCommands) CreateComment(ctx context.Context, input CreateComment
if c.analyticsSvc != nil {
if input.WorkID != nil {
go c.analyticsSvc.IncrementWorkComments(context.Background(), *input.WorkID)
go func() {
if err := c.analyticsSvc.IncrementWorkComments(context.Background(), *input.WorkID); err != nil {
log.Error(err, "failed to increment work comments")
}
}()
}
if input.TranslationID != nil {
go c.analyticsSvc.IncrementTranslationComments(context.Background(), *input.TranslationID)
go func() {
if err := c.analyticsSvc.IncrementTranslationComments(context.Background(), *input.TranslationID); err != nil {
log.Error(err, "failed to increment translation comments")
}
}()
}
}

View File

@ -5,6 +5,7 @@ import (
"errors"
"tercul/internal/app/analytics"
"tercul/internal/domain"
"tercul/internal/platform/log"
)
// LikeCommands contains the command handlers for the like aggregate.
@ -45,10 +46,18 @@ func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*
// After creating the like, increment the appropriate counter.
if c.analyticsSvc != nil {
if input.WorkID != nil {
go c.analyticsSvc.IncrementWorkLikes(context.Background(), *input.WorkID)
go func() {
if err := c.analyticsSvc.IncrementWorkLikes(context.Background(), *input.WorkID); err != nil {
log.Error(err, "failed to increment work likes")
}
}()
}
if input.TranslationID != nil {
go c.analyticsSvc.IncrementTranslationLikes(context.Background(), *input.TranslationID)
go func() {
if err := c.analyticsSvc.IncrementTranslationLikes(context.Background(), *input.TranslationID); err != nil {
log.Error(err, "failed to increment translation likes")
}
}()
}
// Assuming there's a counter for comment likes, which is a reasonable feature to add.
// if input.CommentID != nil {
@ -81,10 +90,18 @@ func (c *LikeCommands) DeleteLike(ctx context.Context, id uint) error {
// After deleting the like, decrement the appropriate counter in the background.
if c.analyticsSvc != nil {
if like.WorkID != nil {
go c.analyticsSvc.DecrementWorkLikes(context.Background(), *like.WorkID)
go func() {
if err := c.analyticsSvc.DecrementWorkLikes(context.Background(), *like.WorkID); err != nil {
log.Error(err, "failed to decrement work likes")
}
}()
}
if like.TranslationID != nil {
go c.analyticsSvc.DecrementTranslationLikes(context.Background(), *like.TranslationID)
go func() {
if err := c.analyticsSvc.DecrementTranslationLikes(context.Background(), *like.TranslationID); err != nil {
log.Error(err, "failed to decrement translation likes")
}
}()
}
}

View File

@ -8,6 +8,7 @@ import (
"tercul/internal/domain"
"tercul/internal/domain/search"
platform_auth "tercul/internal/platform/auth"
"tercul/internal/platform/log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
@ -50,9 +51,9 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *domain.Work) (*doma
return nil, err
}
// Index the work in the search client
err = c.searchClient.IndexWork(ctx, work, "")
if err != nil {
if err := c.searchClient.IndexWork(ctx, work, ""); err != nil {
// Log the error but don't fail the operation
log.FromContext(ctx).Warn(fmt.Sprintf("Failed to index work after creation: %v", err))
}
return work, nil
}
@ -141,7 +142,7 @@ func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
// AnalyzeWork performs linguistic analysis on a work.
func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error {
ctx, span := c.tracer.Start(ctx, "AnalyzeWork")
_, span := c.tracer.Start(ctx, "AnalyzeWork")
defer span.End()
// TODO: implement this
return nil
@ -251,6 +252,7 @@ func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) e
if err == nil && targetWork != nil {
if searchErr := c.searchClient.IndexWork(ctx, targetWork, ""); searchErr != nil {
// Log the error but don't fail the main operation
log.FromContext(ctx).Warn(fmt.Sprintf("Failed to re-index target work after merge: %v", searchErr))
}
}
@ -295,4 +297,4 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error {
}
return nil
}
}

View File

@ -36,7 +36,8 @@ func (s *BaseRepositoryTestSuite) SetupTest() {
// TearDownSuite drops the test table after the suite finishes.
func (s *BaseRepositoryTestSuite) TearDownSuite() {
s.DB.Migrator().DropTable(&testutil.TestEntity{})
err := s.DB.Migrator().DropTable(&testutil.TestEntity{})
s.Require().NoError(err)
}
// TestBaseRepository runs the entire test suite.
@ -79,6 +80,7 @@ func (s *BaseRepositoryTestSuite) TestCreate() {
})
s.Run("should return error for nil context", func() {
//nolint:staticcheck // Testing behavior with nil context is intentional here.
err := s.repo.Create(nil, &testutil.TestEntity{Name: "Test Context"})
s.ErrorIs(err, sql.ErrContextRequired)
})

View File

@ -162,7 +162,9 @@ func (c *CompositeAnalysisCache) Get(ctx context.Context, key string) (*Analysis
// Try Redis cache
if result, err := c.redisCache.Get(ctx, key); err == nil {
// Populate memory cache with Redis result
c.memoryCache.Set(ctx, key, result)
if err := c.memoryCache.Set(ctx, key, result); err != nil {
log.FromContext(ctx).Warn(fmt.Sprintf("Failed to populate memory cache from Redis for key %s: %v", key, err))
}
return result, nil
}

View File

@ -155,14 +155,6 @@ func (a *BasicAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error {
// Helper functions for text analysis
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Note: max was unused and has been removed to keep the code minimal and focused
// makeTextCacheKey builds a stable cache key using a content hash to avoid collisions/leaks

View File

@ -8,9 +8,7 @@ import (
)
const (
callBackBeforeName = "prometheus:before"
callBackAfterName = "prometheus:after"
startTime = "start_time"
startTime = "start_time"
)
type PrometheusPlugin struct {
@ -23,20 +21,44 @@ func (p *PrometheusPlugin) Name() string {
func (p *PrometheusPlugin) Initialize(db *gorm.DB) error {
// Before callbacks
db.Callback().Create().Before("gorm:create").Register(callBackBeforeName, p.before)
db.Callback().Query().Before("gorm:query").Register(callBackBeforeName, p.before)
db.Callback().Update().Before("gorm:update").Register(callBackBeforeName, p.before)
db.Callback().Delete().Before("gorm:delete").Register(callBackBeforeName, p.before)
db.Callback().Row().Before("gorm:row").Register(callBackBeforeName, p.before)
db.Callback().Raw().Before("gorm:raw").Register(callBackBeforeName, p.before)
if err := db.Callback().Create().Before("gorm:create").Register("prometheus:before_create", p.before); err != nil {
return err
}
if err := db.Callback().Query().Before("gorm:query").Register("prometheus:before_query", p.before); err != nil {
return err
}
if err := db.Callback().Update().Before("gorm:update").Register("prometheus:before_update", p.before); err != nil {
return err
}
if err := db.Callback().Delete().Before("gorm:delete").Register("prometheus:before_delete", p.before); err != nil {
return err
}
if err := db.Callback().Row().Before("gorm:row").Register("prometheus:before_row", p.before); err != nil {
return err
}
if err := db.Callback().Raw().Before("gorm:raw").Register("prometheus:before_raw", p.before); err != nil {
return err
}
// After callbacks
db.Callback().Create().After("gorm:create").Register(callBackAfterName, p.after)
db.Callback().Query().After("gorm:query").Register(callBackAfterName, p.after)
db.Callback().Update().After("gorm:update").Register(callBackAfterName, p.after)
db.Callback().Delete().After("gorm:delete").Register(callBackAfterName, p.after)
db.Callback().Row().After("gorm:row").Register(callBackAfterName, p.after)
db.Callback().Raw().After("gorm:raw").Register(callBackAfterName, p.after)
if err := db.Callback().Create().After("gorm:create").Register("prometheus:after_create", p.after); err != nil {
return err
}
if err := db.Callback().Query().After("gorm:query").Register("prometheus:after_query", p.after); err != nil {
return err
}
if err := db.Callback().Update().After("gorm:update").Register("prometheus:after_update", p.after); err != nil {
return err
}
if err := db.Callback().Delete().After("gorm:delete").Register("prometheus:after_delete", p.after); err != nil {
return err
}
if err := db.Callback().Row().After("gorm:row").Register("prometheus:after_row", p.after); err != nil {
return err
}
if err := db.Callback().Raw().After("gorm:raw").Register("prometheus:after_raw", p.after); err != nil {
return err
}
return nil
}

View File

@ -1,6 +1,7 @@
package http
import (
"fmt"
"net/http"
"sync"
"tercul/internal/platform/config"
@ -92,7 +93,11 @@ func RateLimitMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
Warn("Rate limit exceeded")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("Rate limit exceeded. Please try again later."))
if _, err := w.Write([]byte("Rate limit exceeded. Please try again later.")); err != nil {
// We can't write the body, but the header has been sent.
// Log the error for observability.
log.FromContext(r.Context()).Error(err, fmt.Sprintf("Failed to write rate limit response body for clientID %s", clientID))
}
return
}

View File

@ -29,55 +29,6 @@ func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pip
return nil
}
// mockAnalyticsService is a mock implementation of the AnalyticsService interface.
type mockAnalyticsService struct{}
func (m *mockAnalyticsService) IncrementWorkViews(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementWorkShares(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationViews(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
return &domain.WorkStats{}, nil
}
func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
return &domain.TranslationStats{}, nil
}
func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) UpdateWorkComplexity(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) UpdateWorkSentiment(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) UpdateTranslationSentiment(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error {
return nil
}
func (m *mockAnalyticsService) UpdateTrending(ctx context.Context) error { return nil }
func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
return nil, nil
}
// IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories
type IntegrationTestSuite struct {
@ -145,7 +96,7 @@ func (s *IntegrationTestSuite) SetupSuite(testConfig *TestConfig) {
}
s.DB = db
db.AutoMigrate(
err = db.AutoMigrate(
&domain.Work{}, &domain.User{}, &domain.Author{}, &domain.Translation{},
&domain.Comment{}, &domain.Like{}, &domain.Bookmark{}, &domain.Collection{},
&domain.Tag{}, &domain.Category{}, &domain.Book{}, &domain.Publisher{},
@ -154,6 +105,7 @@ func (s *IntegrationTestSuite) SetupSuite(testConfig *TestConfig) {
&domain.LanguageAnalysis{}, &domain.TextMetadata{}, &domain.ReadabilityScore{},
&domain.TranslationStats{}, &TestEntity{}, &domain.CollectionWork{},
)
s.Require().NoError(err, "Failed to migrate database schema")
cfg, err := platform_config.LoadConfig()
if err != nil {

View File

@ -1,7 +1,6 @@
package testutil
import (
"os"
"testing"
"github.com/stretchr/testify/suite"
@ -34,16 +33,6 @@ func (s *BaseSuite) TearDownTest() {
// No-op by default.
}
// getEnv gets an environment variable or returns a default value.
// This is kept as a general utility function.
func getEnv(key, defaultValue string) string {
value, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
return value
}
// SkipIfShort skips a test if the -short flag is provided.
func SkipIfShort(t *testing.T) {
if testing.Short() {