diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 0000000..df27186 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,23 @@ +version = 1 + +[[analyzers]] +name = "secrets" + +[[analyzers]] +name = "test-coverage" + +[[analyzers]] +name = "javascript" + + [analyzers.meta] + plugins = ["react"] + environment = [ + "nodejs", + "cypress" + ] + +[[analyzers]] +name = "go" + + [analyzers.meta] + import_root = "github.com/Azanul/Next-Watch" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..ac698b5 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,46 @@ +name: PR Checks + +on: + pull_request: + branches: [ prod ] + types: [opened, synchronize, reopened] + +jobs: + frontend-checks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.17.x' + - name: Install dependencies + run: npm ci + - name: Run linter + run: npm run lint + # - name: Run tests + # run: npm test + - name: Build + run: npm run build + + backend-checks: + runs-on: ubuntu-latest + working-directory: server + steps: + - uses: actions/checkout@v4 + - name: Build and embed frontend + working-directory: . + run: make frontend + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Install dependencies + run: go mod download + - name: Run tests + run: go test ./... + - name: Run linter + uses: golangci/golangci-lint-action@v6.1.0 diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 2fd9121..c696f1d 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -16,7 +16,7 @@ export default function Home() { }, []); function handleGoogleSignIn() { - router.push(`/auth/signin/google`); + router.push("/auth/signin/google"); } return ( diff --git a/server/embedFrontend.go b/server/embed_frontend.go similarity index 100% rename from server/embedFrontend.go rename to server/embed_frontend.go diff --git a/server/go.mod b/server/go.mod index 2b13a13..44b5b40 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,8 +4,10 @@ go 1.22.7 require ( github.com/99designs/gqlgen v0.17.54 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.9.0 github.com/vektah/gqlparser/v2 v2.5.16 google.golang.org/api v0.198.0 ) @@ -14,6 +16,7 @@ require ( cloud.google.com/go/auth v0.9.4 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect cloud.google.com/go/compute/metadata v0.5.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -21,6 +24,8 @@ require ( github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.13.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect @@ -33,6 +38,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/server/go.sum b/server/go.sum index 1674766..80e1b94 100644 --- a/server/go.sum +++ b/server/go.sum @@ -10,6 +10,8 @@ entgo.io/ent v0.13.1/go.mod h1:qCEmo+biw3ccBn9OyL4ZK5dfpwg++l1Gxwac5B1206A= github.com/99designs/gqlgen v0.17.54 h1:AsF49k/7RJlwA00RQYsYN0T8cQuaosnV/7G1dHC3Uh8= github.com/99designs/gqlgen v0.17.54/go.mod h1:77/+pVe6zlTsz++oUg2m8VLgzdUPHxjoAG3BxI5y8Rc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -88,6 +90,7 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -104,6 +107,8 @@ github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERA github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -207,6 +212,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/server/internal/repository/interfaces.go b/server/internal/repository/interfaces.go new file mode 100644 index 0000000..daca427 --- /dev/null +++ b/server/internal/repository/interfaces.go @@ -0,0 +1,33 @@ +package repository + +import ( + "context" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/google/uuid" + "github.com/pgvector/pgvector-go" +) + +type MovieRepositoryInterface interface { + GetMovies(ctx context.Context, searchTerm string, page, pageSize int) (*MoviePage, error) + GetByID(ctx context.Context, id uuid.UUID) (*models.Movie, error) + GetByTitle(ctx context.Context, title string) (*models.Movie, error) + GetSimilarMovies(ctx context.Context, embedding pgvector.Vector, page, pageSize int) (*MoviePage, error) + Create(ctx context.Context, movie *models.Movie) error + Update(ctx context.Context, movie *models.Movie) error + Delete(ctx context.Context, rating *models.Rating) error +} + +type RatingRepositoryInterface interface { + GetByID(ctx context.Context, ratingID uuid.UUID) (*models.Rating, error) + GetByUserAndMovie(ctx context.Context, userID, movieID uuid.UUID) (*models.Rating, error) + Create(ctx context.Context, rating *models.Rating) error + Update(ctx context.Context, rating *models.Rating) error + Delete(ctx context.Context, ratingID uuid.UUID) (*models.Rating, error) +} + +type UserRepositoryInterface interface { + Create(ctx context.Context, user *models.User) error + GetByEmail(ctx context.Context, email string) (*models.User, error) + Update(ctx context.Context, user *models.User) error +} diff --git a/server/internal/repository/movie_repository.go b/server/internal/repository/movie_repository.go index 9155b32..a487fea 100644 --- a/server/internal/repository/movie_repository.go +++ b/server/internal/repository/movie_repository.go @@ -5,6 +5,7 @@ package repository import ( "context" "database/sql" + "fmt" "github.com/Azanul/Next-Watch/internal/models" "github.com/google/uuid" @@ -15,6 +16,9 @@ type MovieRepository struct { db *sql.DB } +// Checking if MovieRepository implements MovieRepositoryInterface during compile time +var _ MovieRepositoryInterface = (*MovieRepository)(nil) + func NewMovieRepository(db *sql.DB) *MovieRepository { return &MovieRepository{db: db} } @@ -32,58 +36,49 @@ func (r *MovieRepository) GetMovies(ctx context.Context, searchTerm string, page var rows *sql.Rows var err error var totalCount int - if searchTerm == "" { - // Query to get the movies for the current page - query := ` + + query := ` SELECT id, title, genre, year, wiki, plot, director, "cast" - FROM movies - ORDER BY id - LIMIT $1 OFFSET $2 + FROM movies ` - rows, err = r.db.QueryContext(ctx, query, pageSize+1, offset) - if err != nil { - return nil, err - } + countQuery := `SELECT COUNT(*) FROM movies` - countQuery := `SELECT COUNT(*) FROM movies` + if searchTerm != "" { + query += ` + WHERE title ILIKE '%' || $3 || '%' + OR "cast" ILIKE '%' || $3 || '%' + OR director ILIKE '%' || $3 || '%' + ` - err = r.db.QueryRowContext(ctx, countQuery).Scan(&totalCount) - if err != nil { - return nil, err - } + countQuery += ` + WHERE title ILIKE '%' || $1 || '%' + OR "cast" ILIKE '%' || $1 || '%' + OR director ILIKE '%' || $1 || '%' + ` + } + query += " ORDER BY id LIMIT $1 OFFSET $2" + // Query movies + if searchTerm == "" { + rows, err = r.db.QueryContext(ctx, query, pageSize+1, offset) } else { - // Query to get the movies for the current page, including search term filtering - query := ` - SELECT id, title, genre, year, wiki, plot, director, "cast" - FROM movies - WHERE title ILIKE '%' || $3 || '%' - OR "cast" ILIKE '%' || $3 || '%' - OR director ILIKE '%' || $3 || '%' - ORDER BY id - LIMIT $1 OFFSET $2 - ` - rows, err = r.db.QueryContext(ctx, query, pageSize+1, offset, searchTerm) - if err != nil { - return nil, err - } - - countQuery := ` - SELECT COUNT(*) - FROM movies - WHERE title ILIKE '%' || $1 || '%' - OR "cast" ILIKE '%' || $1 || '%' - OR director ILIKE '%' || $1 || '%' - ` + } + if err != nil { + return nil, fmt.Errorf("failed to query movies: %w", err) + } + defer rows.Close() + // Count movies + if searchTerm == "" { + err = r.db.QueryRowContext(ctx, countQuery).Scan(&totalCount) + } else { err = r.db.QueryRowContext(ctx, countQuery, searchTerm).Scan(&totalCount) - if err != nil { - return nil, err - } } - defer rows.Close() + if err != nil { + return nil, fmt.Errorf("failed to count movies: %w", err) + } var movies []*models.Movie for rows.Next() { diff --git a/server/internal/repository/movie_repository_test.go b/server/internal/repository/movie_repository_test.go new file mode 100644 index 0000000..1817e5f --- /dev/null +++ b/server/internal/repository/movie_repository_test.go @@ -0,0 +1,459 @@ +package repository + +import ( + "context" + "database/sql" + "testing" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/pgvector/pgvector-go" + "github.com/stretchr/testify/assert" +) + +func TestMovieRepository_GetMovies(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + searchTerm string + page int + pageSize int + mockSetup func() + want *MoviePage + wantErr bool + }{ + { + name: "Success - No search term", + searchTerm: "", + page: 1, + pageSize: 10, + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "title", "genre", "year", "wiki", "plot", "director", "cast"}). + AddRow(uuid.New(), "Movie 1", "Action", 2021, "wiki1", "plot1", "director1", "cast1"). + AddRow(uuid.New(), "Movie 2", "Comedy", 2022, "wiki2", "plot2", "director2", "cast2") + mock.ExpectQuery("^SELECT (.+) FROM movies").WillReturnRows(rows) + mock.ExpectQuery("^SELECT COUNT").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + }, + want: &MoviePage{ + Movies: []*models.Movie{}, + TotalCount: 2, + HasNextPage: false, + HasPreviousPage: false, + }, + wantErr: false, + }, + { + name: "Success - With search term", + searchTerm: "Action", + page: 1, + pageSize: 10, + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "title", "genre", "year", "wiki", "plot", "director", "cast"}). + AddRow(uuid.New(), "Action Movie", "Action", 2021, "wiki1", "plot1", "director1", "cast1") + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnRows(rows) + mock.ExpectQuery("^SELECT COUNT").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + }, + want: &MoviePage{ + Movies: []*models.Movie{}, + TotalCount: 1, + HasNextPage: false, + HasPreviousPage: false, + }, + wantErr: false, + }, + { + name: "Error - Database query fails", + searchTerm: "", + page: 1, + pageSize: 10, + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM movies").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetMovies(context.Background(), tt.searchTerm, tt.page, tt.pageSize) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.GetMovies() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Equal(t, tt.want.TotalCount, got.TotalCount) + assert.Equal(t, tt.want.HasNextPage, got.HasNextPage) + assert.Equal(t, tt.want.HasPreviousPage, got.HasPreviousPage) + } + }) + } +} + +func TestMovieRepository_GetByID(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + id uuid.UUID + mockSetup func() + want *models.Movie + wantErr bool + }{ + { + name: "Success", + id: uuid.New(), + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "title", "genre", "year", "wiki", "plot", "cast", "embedding"}). + AddRow(uuid.New(), "Movie 1", "Action", 2021, "wiki1", "plot1", "cast1", pgvector.NewVector([]float32{1, 2, 3})) + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnRows(rows) + }, + want: &models.Movie{}, + wantErr: false, + }, + { + name: "Not Found", + id: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnError(sql.ErrNoRows) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + id: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetByID(context.Background(), tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.GetByID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.IsType(t, &models.Movie{}, got) + } + }) + } +} + +func TestMovieRepository_GetByTitle(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + title string + mockSetup func() + want *models.Movie + wantErr bool + }{ + { + name: "Success", + title: "Test Movie", + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "genre", "year", "wiki", "plot", "director", "cast"}). + AddRow(uuid.New(), "Action", 2021, "wiki", "plot", "director", "cast") + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnRows(rows) + }, + want: &models.Movie{Title: "Test Movie"}, + wantErr: false, + }, + { + name: "Not Found", + title: "Non-existent Movie", + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnError(sql.ErrNoRows) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + title: "Error Movie", + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM movies WHERE").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetByTitle(context.Background(), tt.title) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.GetByTitle() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.Equal(t, tt.title, got.Title) + } + }) + } +} + +func TestMovieRepository_GetSimilarMovies(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + embedding pgvector.Vector + page int + pageSize int + mockSetup func() + want *MoviePage + wantErr bool + }{ + { + name: "Success", + embedding: pgvector.NewVector([]float32{1, 2, 3}), + page: 1, + pageSize: 10, + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "title", "genre", "year", "wiki", "plot", "director", "cast"}). + AddRow(uuid.New(), "Similar Movie 1", "Action", 2021, "wiki1", "plot1", "director1", "cast1"). + AddRow(uuid.New(), "Similar Movie 2", "Comedy", 2022, "wiki2", "plot2", "director2", "cast2") + mock.ExpectQuery("^SELECT (.+) FROM movies ORDER BY").WillReturnRows(rows) + mock.ExpectQuery("^SELECT COUNT").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + }, + want: &MoviePage{ + Movies: []*models.Movie{}, + TotalCount: 2, + HasNextPage: false, + HasPreviousPage: false, + }, + wantErr: false, + }, + { + name: "Error - Database query fails", + embedding: pgvector.NewVector([]float32{1, 2, 3}), + page: 1, + pageSize: 10, + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM movies ORDER BY").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetSimilarMovies(context.Background(), tt.embedding, tt.page, tt.pageSize) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.GetSimilarMovies() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Equal(t, tt.want.TotalCount, got.TotalCount) + assert.Equal(t, tt.want.HasNextPage, got.HasNextPage) + assert.Equal(t, tt.want.HasPreviousPage, got.HasPreviousPage) + } + }) + } +} + +func TestMovieRepository_Create(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + movie *models.Movie + mockSetup func() + wantErr bool + }{ + { + name: "Success", + movie: &models.Movie{ + Title: "New Movie", + Genre: "Action", + Year: 2023, + Wiki: "wiki", + Plot: "plot", + Director: "director", + Cast: "cast", + Embedding: pgvector.NewVector([]float32{1, 2, 3}), + }, + mockSetup: func() { + mock.ExpectExec("^INSERT INTO movies").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + movie: &models.Movie{ + Title: "Error Movie", + }, + mockSetup: func() { + mock.ExpectExec("^INSERT INTO movies").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Create(context.Background(), tt.movie) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.Create() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestMovieRepository_Update(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + movie *models.Movie + mockSetup func() + wantErr bool + }{ + { + name: "Success", + movie: &models.Movie{ + ID: uuid.New(), + Title: "Updated Movie", + Genre: "Action", + Year: 2023, + Wiki: "updated wiki", + Plot: "updated plot", + Director: "updated director", + Cast: "updated cast", + Embedding: pgvector.NewVector([]float32{1, 2, 3}), + }, + mockSetup: func() { + mock.ExpectExec("^UPDATE movies").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + movie: &models.Movie{ + ID: uuid.New(), + Title: "Error Movie", + }, + mockSetup: func() { + mock.ExpectExec("^UPDATE movies").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Update(context.Background(), tt.movie) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.Update() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestMovieRepository_Delete(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewMovieRepository(db) + + tests := []struct { + name string + rating *models.Rating + mockSetup func() + wantErr bool + }{ + { + name: "Success", + rating: &models.Rating{ + ID: uuid.New(), + }, + mockSetup: func() { + mock.ExpectExec("^DELETE movies").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + rating: &models.Rating{ + ID: uuid.New(), + }, + mockSetup: func() { + mock.ExpectExec("^DELETE movies").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Delete(context.Background(), tt.rating) + if (err != nil) != tt.wantErr { + t.Errorf("MovieRepository.Delete() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/internal/repository/rating_repository.go b/server/internal/repository/rating_repository.go index fb5e340..0197839 100644 --- a/server/internal/repository/rating_repository.go +++ b/server/internal/repository/rating_repository.go @@ -1,5 +1,3 @@ -// internal/repository/rating_repository.go - package repository import ( @@ -15,6 +13,9 @@ type RatingRepository struct { db *sql.DB } +// Checking if RatingRepository implements RatingRepositoryInterface during compile time +var _ RatingRepositoryInterface = (*RatingRepository)(nil) + func NewRatingRepository(db *sql.DB) *RatingRepository { return &RatingRepository{db: db} } diff --git a/server/internal/repository/rating_repository_test.go b/server/internal/repository/rating_repository_test.go new file mode 100644 index 0000000..bbfa83f --- /dev/null +++ b/server/internal/repository/rating_repository_test.go @@ -0,0 +1,310 @@ +package repository + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestRatingRepository_GetByID(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewRatingRepository(db) + + tests := []struct { + name string + ratingID uuid.UUID + mockSetup func() + want *models.Rating + wantErr bool + }{ + { + name: "Success", + ratingID: uuid.New(), + mockSetup: func() { + rows := sqlmock.NewRows([]string{"user_id", "movie_id", "score", "created_at", "updated_at"}). + AddRow(uuid.New(), uuid.New(), 5, time.Now(), time.Now()) + mock.ExpectQuery("^SELECT (.+) FROM ratings WHERE").WillReturnRows(rows) + }, + want: &models.Rating{}, + wantErr: false, + }, + { + name: "Not Found", + ratingID: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM ratings WHERE").WillReturnError(sql.ErrNoRows) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + ratingID: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM ratings WHERE").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetByID(context.Background(), tt.ratingID) + if (err != nil) != tt.wantErr { + t.Errorf("RatingRepository.GetByID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.IsType(t, &models.Rating{}, got) + } + }) + } +} + +func TestRatingRepository_GetByUserAndMovie(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewRatingRepository(db) + + tests := []struct { + name string + userID uuid.UUID + movieID uuid.UUID + mockSetup func() + want *models.Rating + wantErr bool + }{ + { + name: "Success", + userID: uuid.New(), + movieID: uuid.New(), + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "user_id", "movie_id", "score", "created_at", "updated_at"}). + AddRow(uuid.New(), uuid.New(), uuid.New(), 5, time.Now(), time.Now()) + mock.ExpectQuery("^SELECT (.+) FROM ratings WHERE").WillReturnRows(rows) + }, + want: &models.Rating{}, + wantErr: false, + }, + { + name: "Not Found", + userID: uuid.New(), + movieID: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM ratings WHERE").WillReturnError(sql.ErrNoRows) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + userID: uuid.New(), + movieID: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM ratings WHERE").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetByUserAndMovie(context.Background(), tt.userID, tt.movieID) + if (err != nil) != tt.wantErr { + t.Errorf("RatingRepository.GetByUserAndMovie() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.IsType(t, &models.Rating{}, got) + } + }) + } +} + +func TestRatingRepository_Create(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewRatingRepository(db) + + tests := []struct { + name string + rating *models.Rating + mockSetup func() + wantErr bool + }{ + { + name: "Success", + rating: &models.Rating{ + UserID: uuid.New(), + MovieID: uuid.New(), + Score: 5, + }, + mockSetup: func() { + mock.ExpectExec("^INSERT INTO ratings").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + rating: &models.Rating{ + UserID: uuid.New(), + MovieID: uuid.New(), + Score: 5, + }, + mockSetup: func() { + mock.ExpectExec("^INSERT INTO ratings").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Create(context.Background(), tt.rating) + if (err != nil) != tt.wantErr { + t.Errorf("RatingRepository.Create() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRatingRepository_Update(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewRatingRepository(db) + + tests := []struct { + name string + rating *models.Rating + mockSetup func() + wantErr bool + }{ + { + name: "Success", + rating: &models.Rating{ + ID: uuid.New(), + Score: 4, + }, + mockSetup: func() { + mock.ExpectExec("^UPDATE ratings").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + rating: &models.Rating{ + ID: uuid.New(), + Score: 4, + }, + mockSetup: func() { + mock.ExpectExec("^UPDATE ratings").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Update(context.Background(), tt.rating) + if (err != nil) != tt.wantErr { + t.Errorf("RatingRepository.Update() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRatingRepository_Delete(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewRatingRepository(db) + + tests := []struct { + name string + ratingID uuid.UUID + mockSetup func() + want *models.Rating + wantErr bool + }{ + { + name: "Success", + ratingID: uuid.New(), + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "user_id", "movie_id", "score", "created_at", "updated_at"}). + AddRow(uuid.New(), uuid.New(), uuid.New(), 5, time.Now(), time.Now()) + mock.ExpectQuery("^DELETE FROM ratings WHERE").WillReturnRows(rows) + }, + want: &models.Rating{}, + wantErr: false, + }, + { + name: "Not Found", + ratingID: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^DELETE FROM ratings WHERE").WillReturnError(sql.ErrNoRows) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + ratingID: uuid.New(), + mockSetup: func() { + mock.ExpectQuery("^DELETE FROM ratings WHERE").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.Delete(context.Background(), tt.ratingID) + if (err != nil) != tt.wantErr { + t.Errorf("RatingRepository.Delete() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.IsType(t, &models.Rating{}, got) + } + }) + } +} diff --git a/server/internal/repository/user_repository.go b/server/internal/repository/user_repository.go index fd816d2..fa8f762 100644 --- a/server/internal/repository/user_repository.go +++ b/server/internal/repository/user_repository.go @@ -13,6 +13,9 @@ type UserRepository struct { db *sql.DB } +// Checking if UserRepository implements UserRepositoryInterface during compile time +var _ UserRepositoryInterface = (*UserRepository)(nil) + func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } diff --git a/server/internal/repository/user_repository_test.go b/server/internal/repository/user_repository_test.go new file mode 100644 index 0000000..0b7e59f --- /dev/null +++ b/server/internal/repository/user_repository_test.go @@ -0,0 +1,189 @@ +package repository + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/pgvector/pgvector-go" + "github.com/stretchr/testify/assert" +) + +func TestUserRepository_Create(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + tests := []struct { + name string + user *models.User + mockSetup func() + wantErr bool + }{ + { + name: "Success", + user: &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Name: "Test User", + Role: "user", + }, + mockSetup: func() { + mock.ExpectExec("^INSERT INTO users").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + user: &models.User{ + ID: uuid.New(), + Email: "error@example.com", + Name: "Error User", + Role: "user", + }, + mockSetup: func() { + mock.ExpectExec("^INSERT INTO users").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Create(context.Background(), tt.user) + if (err != nil) != tt.wantErr { + t.Errorf("UserRepository.Create() error = %v, wantErr %v", err, tt.wantErr) + } + assert.Equal(t, 512, len(tt.user.Taste.Slice())) + }) + } +} + +func TestUserRepository_GetByEmail(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + tests := []struct { + name string + email string + mockSetup func() + want *models.User + wantErr bool + }{ + { + name: "Success", + email: "test@example.com", + mockSetup: func() { + rows := sqlmock.NewRows([]string{"id", "role", "taste", "created_at"}). + AddRow(uuid.New(), "user", pgvector.NewVector(make([]float32, 512)), time.Now()) + mock.ExpectQuery("^SELECT (.+) FROM users WHERE").WillReturnRows(rows) + }, + want: &models.User{}, + wantErr: false, + }, + { + name: "Not Found", + email: "notfound@example.com", + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM users WHERE").WillReturnError(sql.ErrNoRows) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + email: "error@example.com", + mockSetup: func() { + mock.ExpectQuery("^SELECT (.+) FROM users WHERE").WillReturnError(sql.ErrConnDone) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := repo.GetByEmail(context.Background(), tt.email) + if (err != nil) != tt.wantErr { + t.Errorf("UserRepository.GetByEmail() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.IsType(t, &models.User{}, got) + assert.Equal(t, tt.email, got.Email) + } + }) + } +} + +func TestUserRepository_Update(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + tests := []struct { + name string + user *models.User + mockSetup func() + wantErr bool + }{ + { + name: "Success", + user: &models.User{ + ID: uuid.New(), + Email: "update@example.com", + Role: "admin", + Taste: pgvector.NewVector(make([]float32, 512)), + }, + mockSetup: func() { + mock.ExpectExec("^UPDATE users").WillReturnResult(sqlmock.NewResult(1, 1)) + }, + wantErr: false, + }, + { + name: "Error", + user: &models.User{ + ID: uuid.New(), + Email: "error@example.com", + Role: "user", + Taste: pgvector.NewVector(make([]float32, 512)), + }, + mockSetup: func() { + mock.ExpectExec("^UPDATE users").WillReturnError(sql.ErrConnDone) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := repo.Update(context.Background(), tt.user) + if (err != nil) != tt.wantErr { + t.Errorf("UserRepository.Update() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/internal/services/movie_service.go b/server/internal/services/movie_service.go index d8d86b4..2b05f10 100644 --- a/server/internal/services/movie_service.go +++ b/server/internal/services/movie_service.go @@ -11,10 +11,10 @@ import ( ) type MovieService struct { - movieRepo *repository.MovieRepository + movieRepo repository.MovieRepositoryInterface } -func NewMovieService(movieRepo *repository.MovieRepository) *MovieService { +func NewMovieService(movieRepo repository.MovieRepositoryInterface) *MovieService { return &MovieService{ movieRepo: movieRepo, } diff --git a/server/internal/services/movie_service_test.go b/server/internal/services/movie_service_test.go new file mode 100644 index 0000000..40b44fd --- /dev/null +++ b/server/internal/services/movie_service_test.go @@ -0,0 +1,176 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/Azanul/Next-Watch/internal/repository" + "github.com/google/uuid" + "github.com/pgvector/pgvector-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockMovieRepository is a mock of MovieRepository +type MockMovieRepository struct { + mock.Mock +} + +func (m *MockMovieRepository) GetMovies(ctx context.Context, searchTerm string, page, pageSize int) (*repository.MoviePage, error) { + args := m.Called(ctx, searchTerm, page, pageSize) + return args.Get(0).(*repository.MoviePage), args.Error(1) +} + +func (m *MockMovieRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Movie, error) { + args := m.Called(ctx, id) + return args.Get(0).(*models.Movie), args.Error(1) +} + +func (m *MockMovieRepository) GetByTitle(ctx context.Context, title string) (*models.Movie, error) { + args := m.Called(ctx, title) + return args.Get(0).(*models.Movie), args.Error(1) +} + +func (m *MockMovieRepository) GetSimilarMovies(ctx context.Context, embedding pgvector.Vector, page, pageSize int) (*repository.MoviePage, error) { + args := m.Called(ctx, embedding, page, pageSize) + return args.Get(0).(*repository.MoviePage), args.Error(1) +} + +func (m *MockMovieRepository) Create(ctx context.Context, movie *models.Movie) error { + args := m.Called(ctx, movie) + return args.Error(0) +} + +func (m *MockMovieRepository) Update(ctx context.Context, movie *models.Movie) error { + args := m.Called(ctx, movie) + return args.Error(0) +} + +func (m *MockMovieRepository) Delete(ctx context.Context, rating *models.Rating) error { + args := m.Called(ctx, rating) + return args.Error(0) +} + +func TestMovieService_GetMovies(t *testing.T) { + mockRepo := new(MockMovieRepository) + service := NewMovieService(mockRepo) + + movieID := uuid.New() + tests := []struct { + name string + page int + pageSize int + mockSetup func() + want *repository.MoviePage + wantErr bool + }{ + { + name: "Success", + page: 1, + pageSize: 10, + mockSetup: func() { + mockRepo.On("GetMovies", mock.Anything, "", 1, 10).Return(&repository.MoviePage{ + Movies: []*models.Movie{{ID: movieID, Title: "Test Movie"}}, + TotalCount: 1, + }, nil) + }, + want: &repository.MoviePage{ + Movies: []*models.Movie{{ID: movieID, Title: "Test Movie"}}, + TotalCount: 1, + }, + wantErr: false, + }, + { + name: "Error", + page: 1, + pageSize: 2, + mockSetup: func() { + mockRepo.On("GetMovies", mock.Anything, "", 1, 2).Return((*repository.MoviePage)(nil), errors.New("database error")) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.GetMovies(context.Background(), tt.page, tt.pageSize) + t.Log(tt.page, tt.name, tt.want, tt.wantErr, got, err) + if (err != nil) != tt.wantErr { + t.Errorf("MovieService.GetMovieByID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil && tt.want != nil { + assert.Equal(t, tt.want.TotalCount, got.TotalCount) + } + }) + mockRepo.ExpectedCalls = nil + mockRepo.Calls = nil + } +} + +func TestMovieService_GetMovieByID(t *testing.T) { + mockRepo := new(MockMovieRepository) + service := NewMovieService(mockRepo) + + movieID := uuid.New() + tests := []struct { + name string + movieID uuid.UUID + mockSetup func() + want *models.Movie + wantErr bool + }{ + { + name: "Success", + movieID: movieID, + mockSetup: func() { + mockRepo.On("GetByID", mock.Anything, movieID).Return(&models.Movie{ + ID: movieID, + Title: "Test Movie", + }, nil) + }, + want: &models.Movie{Title: "Test Movie"}, + wantErr: false, + }, + { + name: "Not Found", + movieID: uuid.New(), + mockSetup: func() { + mockRepo.On("GetByID", mock.Anything, mock.AnythingOfType("uuid.UUID")).Return((*models.Movie)(nil), nil) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + movieID: uuid.New(), + mockSetup: func() { + mockRepo.On("GetByID", mock.Anything, mock.AnythingOfType("uuid.UUID")).Return((*models.Movie)(nil), errors.New("database error")) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.GetMovieByID(context.Background(), tt.movieID) + if (err != nil) != tt.wantErr { + t.Errorf("MovieService.GetMovieByID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil && tt.want != nil { + assert.Equal(t, tt.want.Title, got.Title) + } + }) + mockRepo.ExpectedCalls = nil + mockRepo.Calls = nil + } +} diff --git a/server/internal/services/rating_service.go b/server/internal/services/rating_service.go index 633d6a8..80118fe 100644 --- a/server/internal/services/rating_service.go +++ b/server/internal/services/rating_service.go @@ -12,12 +12,12 @@ import ( ) type RatingService struct { - ratingRepo *repository.RatingRepository - movieRepo *repository.MovieRepository - userRepo *repository.UserRepository + ratingRepo repository.RatingRepositoryInterface + movieRepo repository.MovieRepositoryInterface + userRepo repository.UserRepositoryInterface } -func NewRatingService(ratingRepo *repository.RatingRepository, movieRepo *repository.MovieRepository, userRepo *repository.UserRepository) *RatingService { +func NewRatingService(ratingRepo repository.RatingRepositoryInterface, movieRepo repository.MovieRepositoryInterface, userRepo repository.UserRepositoryInterface) *RatingService { return &RatingService{ ratingRepo: ratingRepo, movieRepo: movieRepo, diff --git a/server/internal/services/rating_service_test.go b/server/internal/services/rating_service_test.go new file mode 100644 index 0000000..35fe4b7 --- /dev/null +++ b/server/internal/services/rating_service_test.go @@ -0,0 +1,275 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/google/uuid" + "github.com/pgvector/pgvector-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockRatingRepository struct { + mock.Mock +} + +func (m *MockRatingRepository) GetByID(ctx context.Context, ratingID uuid.UUID) (*models.Rating, error) { + args := m.Called(ctx, ratingID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Rating), args.Error(1) +} + +func (m *MockRatingRepository) GetByUserAndMovie(ctx context.Context, userID, movieID uuid.UUID) (*models.Rating, error) { + args := m.Called(ctx, userID, movieID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Rating), args.Error(1) +} + +func (m *MockRatingRepository) Create(ctx context.Context, rating *models.Rating) error { + args := m.Called(ctx, rating) + return args.Error(0) +} + +func (m *MockRatingRepository) Update(ctx context.Context, rating *models.Rating) error { + args := m.Called(ctx, rating) + return args.Error(0) +} + +func (m *MockRatingRepository) Delete(ctx context.Context, ratingID uuid.UUID) (*models.Rating, error) { + args := m.Called(ctx, ratingID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Rating), args.Error(1) +} + +func TestRatingService_RateMovie(t *testing.T) { + mockRatingRepo := new(MockRatingRepository) + mockMovieRepo := new(MockMovieRepository) + mockUserRepo := new(MockUserRepository) + service := NewRatingService(mockRatingRepo, mockMovieRepo, mockUserRepo) + + ctx := context.Background() + user := &models.User{ID: uuid.New(), Taste: pgvector.NewVector(make([]float32, 512))} + movieID := uuid.New() + score := float32(4.5) + + tests := []struct { + name string + mockSetup func() + want *models.Rating + wantErr bool + }{ + { + name: "Success - New Rating", + mockSetup: func() { + mockMovieRepo.On("GetByID", ctx, movieID).Return(&models.Movie{ID: movieID, Embedding: pgvector.NewVector(make([]float32, 512))}, nil) + mockRatingRepo.On("GetByUserAndMovie", ctx, user.ID, movieID).Return(nil, nil) + mockRatingRepo.On("Create", ctx, mock.AnythingOfType("*models.Rating")).Return(nil) + mockUserRepo.On("Update", ctx, mock.AnythingOfType("*models.User")).Return(nil) + }, + want: &models.Rating{UserID: user.ID, MovieID: movieID, Score: score}, + wantErr: false, + }, + { + name: "Success - Update Existing Rating", + mockSetup: func() { + mockMovieRepo.On("GetByID", ctx, movieID).Return(&models.Movie{ID: movieID, Embedding: pgvector.NewVector(make([]float32, 512))}, nil) + mockRatingRepo.On("GetByUserAndMovie", ctx, user.ID, movieID).Return(&models.Rating{ID: uuid.New(), UserID: user.ID, MovieID: movieID, Score: 3.0}, nil) + mockRatingRepo.On("Update", ctx, mock.AnythingOfType("*models.Rating")).Return(nil) + mockUserRepo.On("Update", ctx, mock.AnythingOfType("*models.User")).Return(nil) + }, + want: &models.Rating{UserID: user.ID, MovieID: movieID, Score: score}, + wantErr: false, + }, + { + name: "Error - Movie Not Found", + mockSetup: func() { + mockMovieRepo.On("GetByID", ctx, movieID).Return(nil, nil) + }, + want: nil, + wantErr: true, + }, + // Add more test cases as needed + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.RateMovie(ctx, user, movieID, score) + if (err != nil) != tt.wantErr { + t.Errorf("RatingService.RateMovie() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Equal(t, tt.want.UserID, got.UserID) + assert.Equal(t, tt.want.MovieID, got.MovieID) + assert.Equal(t, tt.want.Score, got.Score) + } + }) + } +} + +func TestRatingService_GetRatingByID(t *testing.T) { + mockRatingRepo := new(MockRatingRepository) + service := NewRatingService(mockRatingRepo, nil, nil) + + ctx := context.Background() + ratingID := uuid.New() + + tests := []struct { + name string + mockSetup func() + want *models.Rating + wantErr bool + }{ + { + name: "Success", + mockSetup: func() { + mockRatingRepo.On("GetByID", ctx, ratingID).Return(&models.Rating{ + ID: ratingID, + UserID: uuid.New(), + MovieID: uuid.New(), + Score: 4.5, + }, nil) + }, + want: &models.Rating{ID: ratingID, Score: 4.5}, + wantErr: false, + }, + { + name: "Not Found", + mockSetup: func() { + mockRatingRepo.On("GetByID", ctx, ratingID).Return(nil, nil) + }, + want: nil, + wantErr: true, + }, + // Add more test cases as needed + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.GetRatingByID(ctx, ratingID) + if (err != nil) != tt.wantErr { + t.Errorf("RatingService.GetRatingByID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.Equal(t, tt.want.ID, got.ID) + assert.Equal(t, tt.want.Score, got.Score) + } + }) + } +} + +func TestRatingService_GetRatingByUserAndMovie(t *testing.T) { + mockRatingRepo := new(MockRatingRepository) + service := NewRatingService(mockRatingRepo, nil, nil) + + ctx := context.Background() + userID := uuid.New() + movieID := uuid.New() + + tests := []struct { + name string + mockSetup func() + want *models.Rating + wantErr bool + }{ + { + name: "Success", + mockSetup: func() { + mockRatingRepo.On("GetByUserAndMovie", ctx, userID, movieID).Return(&models.Rating{ + ID: uuid.New(), + UserID: userID, + MovieID: movieID, + Score: 4.5, + }, nil) + }, + want: &models.Rating{UserID: userID, MovieID: movieID, Score: 4.5}, + wantErr: false, + }, + { + name: "Not Found", + mockSetup: func() { + mockRatingRepo.On("GetByUserAndMovie", ctx, userID, movieID).Return(nil, nil) + }, + want: nil, + wantErr: true, + }, + // Add more test cases as needed + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.GetRatingByUserAndMovie(ctx, userID, movieID) + if (err != nil) != tt.wantErr { + t.Errorf("RatingService.GetRatingByUserAndMovie() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.Equal(t, tt.want.UserID, got.UserID) + assert.Equal(t, tt.want.MovieID, got.MovieID) + assert.Equal(t, tt.want.Score, got.Score) + } + }) + } +} + +func TestRatingService_DeleteRating(t *testing.T) { + mockRatingRepo := new(MockRatingRepository) + service := NewRatingService(mockRatingRepo, nil, nil) + + ctx := context.Background() + ratingID := uuid.New() + + tests := []struct { + name string + mockSetup func() + want bool + wantErr bool + }{ + { + name: "Success", + mockSetup: func() { + mockRatingRepo.On("Delete", ctx, ratingID).Return(&models.Rating{}, nil) + }, + want: true, + wantErr: false, + }, + { + name: "Error", + mockSetup: func() { + mockRatingRepo.On("Delete", ctx, ratingID).Return(nil, errors.New("database error")) + }, + want: false, + wantErr: true, + }, + // Add more test cases as needed + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.DeleteRating(ctx, ratingID) + if (err != nil) != tt.wantErr { + t.Errorf("RatingService.DeleteRating() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/server/internal/services/user_service.go b/server/internal/services/user_service.go index 4f81cb6..964f694 100644 --- a/server/internal/services/user_service.go +++ b/server/internal/services/user_service.go @@ -9,10 +9,10 @@ import ( ) type UserService struct { - userRepo *repository.UserRepository + userRepo repository.UserRepositoryInterface } -func NewUserService(userRepo *repository.UserRepository, movieRepo *repository.MovieRepository) *UserService { +func NewUserService(userRepo repository.UserRepositoryInterface) *UserService { return &UserService{ userRepo: userRepo, } @@ -32,3 +32,7 @@ func (s *UserService) CreateUser(ctx context.Context, user *models.User) error { func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { return s.userRepo.GetByEmail(ctx, email) } + +func (s *UserService) UpdateUser(ctx context.Context, user *models.User) error { + return s.userRepo.Update(ctx, user) +} diff --git a/server/internal/services/user_service_test.go b/server/internal/services/user_service_test.go new file mode 100644 index 0000000..00641d7 --- /dev/null +++ b/server/internal/services/user_service_test.go @@ -0,0 +1,205 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/Azanul/Next-Watch/internal/models" + "github.com/google/uuid" + "github.com/pgvector/pgvector-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error { + args := m.Called(ctx, user) + return args.Error(0) +} + +func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { + args := m.Called(ctx, email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.User), args.Error(1) +} + +func (m *MockUserRepository) Update(ctx context.Context, user *models.User) error { + args := m.Called(ctx, user) + return args.Error(0) +} + +func TestUserService_CreateUser(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewUserService(mockRepo) + + tests := []struct { + name string + user *models.User + mockSetup func() + wantErr bool + }{ + { + name: "Success", + user: &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Name: "Test User", + Role: "user", + Taste: pgvector.NewVector(make([]float32, 512)), + }, + mockSetup: func() { + mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*models.User")).Return(nil) + }, + wantErr: false, + }, + { + name: "Error", + user: &models.User{ + ID: uuid.New(), + Email: "error@example.com", + Name: "Error User", + Role: "user", + Taste: pgvector.NewVector(make([]float32, 512)), + }, + mockSetup: func() { + mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*models.User")).Return(errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := service.CreateUser(context.Background(), tt.user) + if (err != nil) != tt.wantErr { + t.Errorf("UserService.CreateUser() error = %v, wantErr %v", err, tt.wantErr) + } + }) + mockRepo.ExpectedCalls = nil + mockRepo.Calls = nil + } +} + +func TestUserService_GetUserByEmail(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewUserService(mockRepo) + + tests := []struct { + name string + email string + mockSetup func() + want *models.User + wantErr bool + }{ + { + name: "Success", + email: "test@example.com", + mockSetup: func() { + mockRepo.On("GetByEmail", mock.Anything, "test@example.com").Return(&models.User{ + ID: uuid.New(), + Email: "test@example.com", + Name: "Test User", + Role: "user", + Taste: pgvector.NewVector(make([]float32, 512)), + }, nil) + }, + want: &models.User{Email: "test@example.com"}, + wantErr: false, + }, + { + name: "Not Found", + email: "notfound@example.com", + mockSetup: func() { + mockRepo.On("GetByEmail", mock.Anything, "notfound@example.com").Return(nil, nil) + }, + want: nil, + wantErr: false, + }, + { + name: "Error", + email: "error@example.com", + mockSetup: func() { + mockRepo.On("GetByEmail", mock.Anything, "error@example.com").Return(nil, errors.New("database error")) + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + got, err := service.GetUserByEmail(context.Background(), tt.email) + if (err != nil) != tt.wantErr { + t.Errorf("UserService.GetUserByEmail() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != nil { + assert.Equal(t, tt.want.Email, got.Email) + } + }) + } +} + +func TestUserService_UpdateUser(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewUserService(mockRepo) + + tests := []struct { + name string + user *models.User + mockSetup func() + wantErr bool + }{ + { + name: "Success", + user: &models.User{ + ID: uuid.New(), + Email: "update@example.com", + Name: "Updated User", + Role: "admin", + Taste: pgvector.NewVector(make([]float32, 512)), + }, + mockSetup: func() { + mockRepo.On("Update", mock.Anything, mock.AnythingOfType("*models.User")).Return(nil) + }, + wantErr: false, + }, + { + name: "Error", + user: &models.User{ + ID: uuid.New(), + Email: "error@example.com", + Name: "Error User", + Role: "user", + Taste: pgvector.NewVector(make([]float32, 512)), + }, + mockSetup: func() { + mockRepo.On("Update", mock.Anything, mock.AnythingOfType("*models.User")).Return(errors.New("database error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := service.UpdateUser(context.Background(), tt.user) + if (err != nil) != tt.wantErr { + t.Errorf("UserService.UpdateUser() error = %v, wantErr %v", err, tt.wantErr) + } + }) + mockRepo.ExpectedCalls = nil + mockRepo.Calls = nil + } +} diff --git a/server/server.go b/server/server.go index 78c5a9f..67fae60 100644 --- a/server/server.go +++ b/server/server.go @@ -34,7 +34,7 @@ func main() { movieRepo := repository.NewMovieRepository(db) ratingRepo := repository.NewRatingRepository(db) - userService := services.NewUserService(userRepo, movieRepo) + userService := services.NewUserService(userRepo) movieService := services.NewMovieService(movieRepo) ratingService := services.NewRatingService(ratingRepo, movieRepo, userRepo) recommendationService := services.NewRecommendationService(ratingRepo, movieRepo)