diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..bfff4a1 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,46 @@ +name: Run unit tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + test: + name: Test + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: moviedb + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '^1.21' + + - name: Install golang-migrate + run: | + curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz + sudo mv migrate /usr/bin + which migrate + + - name: Run migrations + run: make migrate-up + + - name: Test + run: make run-test diff --git a/Insomnia_Collection.yaml b/Insomnia.yaml similarity index 100% rename from Insomnia_Collection.yaml rename to Insomnia.yaml diff --git a/Makefile b/Makefile index 3bdeeda..51dc96c 100644 --- a/Makefile +++ b/Makefile @@ -48,4 +48,10 @@ generate-image: podman build --tag=rating --target=rating . podman build --tag=movie --target=movie . -.PHONY: create-consul delete-consul proto-generate create-pulsar delete-pulsar create-postgres delete-postgres create-migration migrate-up migrate-down generate-dbdocs generate-schema generate-sqlc generate-image \ No newline at end of file +generate-mock: + mockgen -package mockdb -destination database/mockdb/store.go main/database/db Store + +run-test: + go test ./... + +.PHONY: create-consul delete-consul proto-generate create-pulsar delete-pulsar create-postgres delete-postgres create-migration migrate-up migrate-down generate-dbdocs generate-schema generate-sqlc generate-image generate-mock run-test \ No newline at end of file diff --git a/database/db/main_test.go b/database/db/main_test.go new file mode 100644 index 0000000..3b5db61 --- /dev/null +++ b/database/db/main_test.go @@ -0,0 +1,24 @@ +package db + +import ( + "context" + "log" + "main/utils" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var testStore Store + +func TestMain(m *testing.M) { + cfg := utils.LoadConfig("../../.env") + connPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL) + if err != nil { + log.Fatal("cannot connect to db:", err) + } + + testStore = NewStore(connPool) + os.Exit(m.Run()) +} diff --git a/database/db/movies_test.go b/database/db/movies_test.go new file mode 100644 index 0000000..3178d06 --- /dev/null +++ b/database/db/movies_test.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + "main/utils" + "testing" + + "github.com/stretchr/testify/require" +) + +func createRandomMovie(t *testing.T) *Movie { + arg := &CreateMovieParams{ + ID: utils.RandomString(8), + Title: utils.RandomString(8), + Description: utils.RandomString(16), + Director: utils.RandomString(8), + } + + movie, err := testStore.CreateMovie(context.Background(), arg) + require.NoError(t, err) + require.NotEmpty(t, movie) + + require.Equal(t, arg.ID, movie.ID) + require.Equal(t, arg.Title, movie.Title) + require.Equal(t, arg.Description, movie.Description) + require.Equal(t, arg.Director, movie.Director) + + return movie +} + +func TestCreateMovie(t *testing.T) { + createRandomMovie(t) +} + +func TestGetMovie(t *testing.T) { + movie1 := createRandomMovie(t) + movie2, err := testStore.GetMovie(context.Background(), movie1.ID) + require.NoError(t, err) + require.NotEmpty(t, movie2) + + require.Equal(t, movie1.ID, movie2.ID) + require.Equal(t, movie1.Title, movie2.Title) + require.Equal(t, movie1.Description, movie2.Description) + require.Equal(t, movie1.Director, movie2.Director) +} diff --git a/database/db/ratings_test.go b/database/db/ratings_test.go new file mode 100644 index 0000000..828bc68 --- /dev/null +++ b/database/db/ratings_test.go @@ -0,0 +1,60 @@ +package db + +import ( + "context" + "main/utils" + "testing" + + "github.com/stretchr/testify/require" +) + +func createRandomRating(t *testing.T, movieId, recordType string) *Rating { + arg := &CreateRatingParams{ + MovieID: movieId, + RecordType: recordType, + UserID: utils.RandomString(8), + Value: int32(utils.RandomInt(0, 10)), + } + + rating, err := testStore.CreateRating(context.Background(), arg) + require.NoError(t, err) + require.NotEmpty(t, rating) + + require.Equal(t, arg.MovieID, rating.MovieID) + require.Equal(t, arg.RecordType, rating.RecordType) + require.Equal(t, arg.UserID, rating.UserID) + require.Equal(t, arg.Value, rating.Value) + + return rating +} + +func TestCreateRating(t *testing.T) { + movieId := utils.RandomString(8) + recordType := utils.RandomString(8) + createRandomRating(t, movieId, recordType) +} + +func TestListRatings(t *testing.T) { + movieId := utils.RandomString(8) + recordType := utils.RandomString(8) + + var lastAccount *Rating + for i := 0; i < 10; i++ { + lastAccount = createRandomRating(t, movieId, recordType) + } + + arg := &ListRatingsParams{ + MovieID: movieId, + RecordType: recordType, + } + + ratings, err := testStore.ListRatings(context.Background(), arg) + require.NoError(t, err) + require.NotEmpty(t, ratings) + + for _, rating := range ratings { + require.NotEmpty(t, rating) + require.Equal(t, lastAccount.MovieID, rating.MovieID) + require.Equal(t, lastAccount.RecordType, rating.RecordType) + } +} diff --git a/database/mockdb/store.go b/database/mockdb/store.go new file mode 100644 index 0000000..86bb19d --- /dev/null +++ b/database/mockdb/store.go @@ -0,0 +1,188 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: main/database/db (interfaces: Store) +// +// Generated by this command: +// +// mockgen -package mockdb -destination database/mockdb/store.go main/database/db Store +// +// Package mockdb is a generated GoMock package. +package mockdb + +import ( + context "context" + db "main/database/db" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// CreateMovie mocks base method. +func (m *MockStore) CreateMovie(arg0 context.Context, arg1 *db.CreateMovieParams) (*db.Movie, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateMovie", arg0, arg1) + ret0, _ := ret[0].(*db.Movie) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateMovie indicates an expected call of CreateMovie. +func (mr *MockStoreMockRecorder) CreateMovie(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMovie", reflect.TypeOf((*MockStore)(nil).CreateMovie), arg0, arg1) +} + +// CreateRating mocks base method. +func (m *MockStore) CreateRating(arg0 context.Context, arg1 *db.CreateRatingParams) (*db.Rating, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateRating", arg0, arg1) + ret0, _ := ret[0].(*db.Rating) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateRating indicates an expected call of CreateRating. +func (mr *MockStoreMockRecorder) CreateRating(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRating", reflect.TypeOf((*MockStore)(nil).CreateRating), arg0, arg1) +} + +// DeleteMovie mocks base method. +func (m *MockStore) DeleteMovie(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteMovie", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteMovie indicates an expected call of DeleteMovie. +func (mr *MockStoreMockRecorder) DeleteMovie(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMovie", reflect.TypeOf((*MockStore)(nil).DeleteMovie), arg0, arg1) +} + +// DeleteRating mocks base method. +func (m *MockStore) DeleteRating(arg0 context.Context, arg1 int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRating", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRating indicates an expected call of DeleteRating. +func (mr *MockStoreMockRecorder) DeleteRating(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRating", reflect.TypeOf((*MockStore)(nil).DeleteRating), arg0, arg1) +} + +// GetMovie mocks base method. +func (m *MockStore) GetMovie(arg0 context.Context, arg1 string) (*db.Movie, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMovie", arg0, arg1) + ret0, _ := ret[0].(*db.Movie) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMovie indicates an expected call of GetMovie. +func (mr *MockStoreMockRecorder) GetMovie(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMovie", reflect.TypeOf((*MockStore)(nil).GetMovie), arg0, arg1) +} + +// GetRating mocks base method. +func (m *MockStore) GetRating(arg0 context.Context, arg1 int64) (*db.Rating, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRating", arg0, arg1) + ret0, _ := ret[0].(*db.Rating) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRating indicates an expected call of GetRating. +func (mr *MockStoreMockRecorder) GetRating(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRating", reflect.TypeOf((*MockStore)(nil).GetRating), arg0, arg1) +} + +// ListMovies mocks base method. +func (m *MockStore) ListMovies(arg0 context.Context, arg1 *db.ListMoviesParams) ([]*db.Movie, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListMovies", arg0, arg1) + ret0, _ := ret[0].([]*db.Movie) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListMovies indicates an expected call of ListMovies. +func (mr *MockStoreMockRecorder) ListMovies(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMovies", reflect.TypeOf((*MockStore)(nil).ListMovies), arg0, arg1) +} + +// ListRatings mocks base method. +func (m *MockStore) ListRatings(arg0 context.Context, arg1 *db.ListRatingsParams) ([]*db.Rating, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRatings", arg0, arg1) + ret0, _ := ret[0].([]*db.Rating) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRatings indicates an expected call of ListRatings. +func (mr *MockStoreMockRecorder) ListRatings(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRatings", reflect.TypeOf((*MockStore)(nil).ListRatings), arg0, arg1) +} + +// UpdateMovie mocks base method. +func (m *MockStore) UpdateMovie(arg0 context.Context, arg1 *db.UpdateMovieParams) (*db.Movie, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateMovie", arg0, arg1) + ret0, _ := ret[0].(*db.Movie) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateMovie indicates an expected call of UpdateMovie. +func (mr *MockStoreMockRecorder) UpdateMovie(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMovie", reflect.TypeOf((*MockStore)(nil).UpdateMovie), arg0, arg1) +} + +// UpdateRating mocks base method. +func (m *MockStore) UpdateRating(arg0 context.Context, arg1 *db.UpdateRatingParams) (*db.Rating, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateRating", arg0, arg1) + ret0, _ := ret[0].(*db.Rating) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateRating indicates an expected call of UpdateRating. +func (mr *MockStoreMockRecorder) UpdateRating(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateRating", reflect.TypeOf((*MockStore)(nil).UpdateRating), arg0, arg1) +} diff --git a/discovery/memory/memory.go b/discovery/memory/memory.go index c08573e..b38bbcf 100644 --- a/discovery/memory/memory.go +++ b/discovery/memory/memory.go @@ -8,13 +8,13 @@ import ( "time" ) -type serviceName string -type instanceID string +type ServiceName string +type InstanceID string // Registry defines an in-momory service registry. type Registry struct { sync.RWMutex - serviceAddrs map[serviceName]map[instanceID]*serviceInstance + serviceAddrs map[ServiceName]map[InstanceID]*serviceInstance } type serviceInstance struct { @@ -25,19 +25,19 @@ type serviceInstance struct { // NewRegistry creates a new in-memory service registry instance. func NewRegistry() *Registry { return &Registry{ - serviceAddrs: map[serviceName]map[instanceID]*serviceInstance{}, + serviceAddrs: map[ServiceName]map[InstanceID]*serviceInstance{}, } } // Register creates a service record in the registry. -func (r *Registry) Register(ctx context.Context, instanceId instanceID, serviceName serviceName, hostPort string) error { +func (r *Registry) Register(ctx context.Context, instanceId string, serviceName string, hostPort string) error { r.Lock() defer r.Unlock() - if _, ok := r.serviceAddrs[serviceName]; !ok { - r.serviceAddrs[serviceName] = map[instanceID]*serviceInstance{} + if _, ok := r.serviceAddrs[ServiceName(serviceName)]; !ok { + r.serviceAddrs[ServiceName(serviceName)] = map[InstanceID]*serviceInstance{} } - r.serviceAddrs[serviceName][instanceId] = &serviceInstance{ + r.serviceAddrs[ServiceName(serviceName)][InstanceID(instanceId)] = &serviceInstance{ hostPort: hostPort, lastActive: time.Now(), } @@ -45,42 +45,42 @@ func (r *Registry) Register(ctx context.Context, instanceId instanceID, serviceN } // Deregister removes a service record from the registry. -func (r *Registry) Deregister(ctx context.Context, instanceId instanceID, serviceName serviceName) error { +func (r *Registry) Deregister(ctx context.Context, instanceId string, serviceName string) error { r.Lock() defer r.Unlock() - if _, ok := r.serviceAddrs[serviceName]; !ok { + if _, ok := r.serviceAddrs[ServiceName(serviceName)]; !ok { return nil } - delete(r.serviceAddrs[serviceName], instanceId) + delete(r.serviceAddrs[ServiceName(serviceName)], InstanceID(instanceId)) return nil } // ReportHealthyState is a push mechanism for reporting healthy state to the registry. -func (r *Registry) ReportHealthyState(instanceId instanceID, serviceName serviceName) error { +func (r *Registry) ReportHealthyState(instanceId string, serviceName string) error { r.Lock() defer r.Unlock() - if _, ok := r.serviceAddrs[serviceName]; !ok { + if _, ok := r.serviceAddrs[ServiceName(serviceName)]; !ok { return errors.New("service is not registered yet") } - if _, ok := r.serviceAddrs[serviceName][instanceId]; !ok { + if _, ok := r.serviceAddrs[ServiceName(serviceName)][InstanceID(instanceId)]; !ok { return errors.New("service instance is not registered yet") } - r.serviceAddrs[serviceName][instanceId].lastActive = time.Now() + r.serviceAddrs[ServiceName(serviceName)][InstanceID(instanceId)].lastActive = time.Now() return nil } // ServiceAddresses returns the list of addresses of active instances of the given service. -func (r *Registry) ServiceAddresses(ctx context.Context, serviceName serviceName) ([]string, error) { +func (r *Registry) ServiceAddresses(ctx context.Context, serviceName string) ([]string, error) { r.RLock() defer r.RUnlock() - if len(r.serviceAddrs[serviceName]) == 0 { + if len(r.serviceAddrs[ServiceName(serviceName)]) == 0 { return nil, discovery.ErrNotFound } var res []string - for _, i := range r.serviceAddrs[serviceName] { + for _, i := range r.serviceAddrs[ServiceName(serviceName)] { if !i.lastActive.Before(time.Now().Add(-5 * time.Second)) { res = append(res, i.hostPort) } diff --git a/go.mod b/go.mod index a2c74d1..b23e3d5 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,16 @@ module main -go 1.21.4 +go 1.21 require ( github.com/apache/pulsar-client-go v0.11.1 + github.com/google/go-cmp v0.5.9 github.com/google/uuid v1.4.0 github.com/hashicorp/consul/api v1.26.1 github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/jackc/pgx/v5 v5.5.0 + github.com/stretchr/testify v1.8.3 + go.uber.org/mock v0.3.0 google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 ) @@ -24,6 +27,7 @@ require ( github.com/bits-and-blooms/bitset v1.4.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/danieljoos/wincred v1.1.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/fatih/color v1.14.1 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect @@ -54,6 +58,7 @@ require ( github.com/mtibben/percent v0.2.1 // indirect github.com/pierrec/lz4 v2.0.5+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.11.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.26.0 // indirect diff --git a/go.sum b/go.sum index e641126..683cab0 100644 --- a/go.sum +++ b/go.sum @@ -288,6 +288,8 @@ github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= +go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/metadata/cmd/main.go b/metadata/cmd/main.go index aa14909..e9a37c9 100644 --- a/metadata/cmd/main.go +++ b/metadata/cmd/main.go @@ -8,9 +8,9 @@ import ( "main/database/db" "main/discovery" "main/discovery/consul" - "main/metadata/controller" grpchandler "main/metadata/handler/grpc" "main/metadata/repository/postgres" + "main/metadata/service" "main/rpc" "main/utils" "net" @@ -60,7 +60,7 @@ func main() { store := db.NewStore(conn) repo := postgres.New(store) - svc := controller.New(repo) + svc := service.New(repo) h := grpchandler.New(svc) listener, err := net.Listen("tcp", hostPort) diff --git a/metadata/handler/api/http.go b/metadata/handler/api/http.go index 2c57b57..01ceab0 100644 --- a/metadata/handler/api/http.go +++ b/metadata/handler/api/http.go @@ -4,19 +4,19 @@ import ( "encoding/json" "errors" "log" - "main/metadata/controller" "main/metadata/model" "main/metadata/repository" + "main/metadata/service" "net/http" ) // Handler defines a movie metadata HTTP handler. type Handler struct { - ctrl *controller.MetadataService + ctrl *service.MetadataService } // New creates a new movie metadata HTTP handler. -func New(ctrl *controller.MetadataService) *Handler { +func New(ctrl *service.MetadataService) *Handler { return &Handler{ ctrl: ctrl, } diff --git a/metadata/handler/grpc/pb.go b/metadata/handler/grpc/pb.go index 9744b36..5c15120 100644 --- a/metadata/handler/grpc/pb.go +++ b/metadata/handler/grpc/pb.go @@ -3,8 +3,8 @@ package grpc import ( "context" "errors" - "main/metadata/controller" "main/metadata/model" + "main/metadata/service" "main/rpc" "google.golang.org/grpc/codes" @@ -14,11 +14,11 @@ import ( // Handler defines a movie metadata gRPC handler. type Handler struct { rpc.UnimplementedMetadataServiceServer - svc *controller.MetadataService + svc *service.MetadataService } // New creates a new movie metadata gRPC handler. -func New(svc *controller.MetadataService) *Handler { +func New(svc *service.MetadataService) *Handler { return &Handler{ svc: svc, } @@ -31,7 +31,7 @@ func (h *Handler) GetMetadata(ctx context.Context, req *rpc.GetMetadataRequest) } m, err := h.svc.GetMetadata(ctx, req.MovieId) - if err != nil && errors.Is(err, controller.ErrNotFound) { + if err != nil && errors.Is(err, service.ErrNotFound) { return nil, status.Errorf(codes.NotFound, err.Error()) } else if err != nil { return nil, status.Errorf(codes.Internal, err.Error()) @@ -51,7 +51,7 @@ func (h *Handler) PutMetadata(ctx context.Context, req *rpc.PutMetadataRequest) id := req.Metadata.MovieId metadata := model.MetadataFromProto(req.Metadata) err := h.svc.PutMetadata(ctx, id, metadata) - if err != nil && errors.Is(err, controller.ErrNotFound) { + if err != nil && errors.Is(err, service.ErrNotFound) { return nil, status.Errorf(codes.NotFound, err.Error()) } else if err != nil { return nil, status.Errorf(codes.Internal, err.Error()) diff --git a/metadata/handler/grpc/pb_test.go b/metadata/handler/grpc/pb_test.go new file mode 100644 index 0000000..21e034e --- /dev/null +++ b/metadata/handler/grpc/pb_test.go @@ -0,0 +1 @@ +package grpc diff --git a/metadata/repository/postgres/postgres.go b/metadata/repository/postgres/postgres.go index db3be1b..95cb02b 100644 --- a/metadata/repository/postgres/postgres.go +++ b/metadata/repository/postgres/postgres.go @@ -48,18 +48,3 @@ func (r *Repository) Put(ctx context.Context, id string, metadata *model.Metadat }) return err } - -// CREATE TABLE IF NOT EXISTS "movies" ( -// "id" text PRIMARY KEY, -// "title" text UNIQUE NOT NULL, -// "description" text NOT NULL, -// "director" text NOT NULL -// ); - -// CREATE TABLE IF NOT EXISTS "ratings" ( -// "id" bigserial PRIMARY KEY, -// "movie_id" text NOT NULL, -// "record_type" text NOT NULL, -// "user_id" text NOT NULL, -// "value" integer NOT NULL -// ); diff --git a/metadata/controller/metadata.go b/metadata/service/controller.go similarity index 98% rename from metadata/controller/metadata.go rename to metadata/service/controller.go index ae7d130..c621f8c 100644 --- a/metadata/controller/metadata.go +++ b/metadata/service/controller.go @@ -1,4 +1,4 @@ -package controller +package service import ( "context" diff --git a/metadata/testutil/testutil.go b/metadata/testutil/testutil.go new file mode 100644 index 0000000..b205984 --- /dev/null +++ b/metadata/testutil/testutil.go @@ -0,0 +1,15 @@ +package testutil + +import ( + grpchandler "main/metadata/handler/grpc" + "main/metadata/repository/memory" + "main/metadata/service" + "main/rpc" +) + +// NewTestMetadataGRPCServer creates a new metadata gRPC server to be used in tests. +func NewTestMetadataGRPCServer() rpc.MetadataServiceServer { + r := memory.New() + svc := service.New(r) + return grpchandler.New(svc) +} diff --git a/movie/cmd/main.go b/movie/cmd/main.go index 55ca7a2..36a41eb 100644 --- a/movie/cmd/main.go +++ b/movie/cmd/main.go @@ -7,10 +7,10 @@ import ( "log" "main/discovery" "main/discovery/consul" - "main/movie/controller" metadatagateway "main/movie/gateway/metadata/grpc" ratinggateway "main/movie/gateway/rating/grpc" grpchandler "main/movie/handler/grpc" + "main/movie/service" "main/rpc" "main/utils" "net" @@ -53,7 +53,7 @@ func main() { metadataGateway := metadatagateway.New(registry) ratingGateway := ratinggateway.New(registry) - svc := controller.New(ratingGateway, metadataGateway) + svc := service.New(ratingGateway, metadataGateway) h := grpchandler.New(svc) listener, err := net.Listen("tcp", hostPort) diff --git a/movie/handler/api/http.go b/movie/handler/api/http.go index 76964e7..9e3ba23 100644 --- a/movie/handler/api/http.go +++ b/movie/handler/api/http.go @@ -4,17 +4,17 @@ import ( "encoding/json" "errors" "log" - "main/movie/controller" + "main/movie/service" "net/http" ) // Handler defines a movie handler type Handler struct { - ctrl *controller.MovieService + ctrl *service.MovieService } // New creates a new movie HTTP handler. -func New(ctrl *controller.MovieService) *Handler { +func New(ctrl *service.MovieService) *Handler { return &Handler{ ctrl: ctrl, } @@ -25,7 +25,7 @@ func (h *Handler) GetMovieDetails(w http.ResponseWriter, r *http.Request) { id := r.FormValue("id") ctx := r.Context() details, err := h.ctrl.Get(ctx, id) - if err != nil && errors.Is(err, controller.ErrNotFound) { + if err != nil && errors.Is(err, service.ErrNotFound) { w.WriteHeader(http.StatusNotFound) return } else if err != nil { diff --git a/movie/handler/grpc/pb.go b/movie/handler/grpc/pb.go index 60b55ca..bf15963 100644 --- a/movie/handler/grpc/pb.go +++ b/movie/handler/grpc/pb.go @@ -4,7 +4,7 @@ import ( "context" "errors" "main/metadata/model" - "main/movie/controller" + "main/movie/service" "main/rpc" "google.golang.org/grpc/codes" @@ -14,11 +14,11 @@ import ( // Handler defines a movie gRPC handler. type Handler struct { rpc.UnimplementedMovieServiceServer - svc *controller.MovieService + svc *service.MovieService } // New creates a new movie gRPC handler. -func New(svc *controller.MovieService) *Handler { +func New(svc *service.MovieService) *Handler { return &Handler{ svc: svc, } @@ -30,16 +30,19 @@ func (h *Handler) GetMovieDetails(ctx context.Context, req *rpc.GetMovieDetailsR } m, err := h.svc.Get(ctx, req.MovieId) - if err != nil && errors.Is(err, controller.ErrNotFound) { + if err != nil && errors.Is(err, service.ErrNotFound) { return nil, status.Errorf(codes.NotFound, err.Error()) } else if err != nil { return nil, status.Errorf(codes.Internal, err.Error()) } + md := model.MetadataToProto(&m.Metadata) + r := m.Rating + return &rpc.GetMovieDetailsResponse{ MovieDetails: &rpc.MovieDetails{ - Rating: *m.Rating, - Metadata: model.MetadataToProto(&m.Metadata), + Rating: r, + Metadata: md, }, }, nil } diff --git a/movie/model/movie.go b/movie/model/movie.go index f4c2ba5..f4b2191 100644 --- a/movie/model/movie.go +++ b/movie/model/movie.go @@ -4,6 +4,6 @@ import "main/metadata/model" // MovieDetails includes movie metadata and its aggregated rating. type MovieDetails struct { - Rating *float64 `json:"rating,omitempty"` + Rating float64 `json:"rating,omitempty"` Metadata model.Metadata `json:"metadata"` } diff --git a/movie/controller/movie.go b/movie/service/controller.go similarity index 97% rename from movie/controller/movie.go rename to movie/service/controller.go index 7397b20..2c236f0 100644 --- a/movie/controller/movie.go +++ b/movie/service/controller.go @@ -1,4 +1,4 @@ -package controller +package service import ( "context" @@ -54,7 +54,7 @@ func (c *MovieService) Get(ctx context.Context, id string) (*model.MovieDetails, } else if err != nil { return nil, err } else { - details.Rating = &rating + details.Rating = rating } return details, nil diff --git a/movie/testutil/testutil.go b/movie/testutil/testutil.go new file mode 100644 index 0000000..ee13d4b --- /dev/null +++ b/movie/testutil/testutil.go @@ -0,0 +1,18 @@ +package testutil + +import ( + "main/discovery" + metadatagateway "main/movie/gateway/metadata/grpc" + ratinggateway "main/movie/gateway/rating/grpc" + grpchandler "main/movie/handler/grpc" + "main/movie/service" + "main/rpc" +) + +// NewTestMovieGRPCServer creates a new movie gRPC server to be used in tests. +func NewTestMovieGRPCServer(registry discovery.Registry) rpc.MovieServiceServer { + metadataGateway := metadatagateway.New(registry) + ratingGateway := ratinggateway.New(registry) + ctrl := service.New(ratingGateway, metadataGateway) + return grpchandler.New(ctrl) +} diff --git a/rating/cmd/main.go b/rating/cmd/main.go index 7c0a45a..d4bfb53 100644 --- a/rating/cmd/main.go +++ b/rating/cmd/main.go @@ -8,9 +8,9 @@ import ( "main/database/db" "main/discovery" "main/discovery/consul" - "main/rating/controller" grpchandler "main/rating/handler/grpc" "main/rating/repository/postgres" + "main/rating/service" "main/rpc" "main/utils" "net" @@ -59,7 +59,7 @@ func main() { store := db.NewStore(conn) repo := postgres.New(store) - svc := controller.New(repo, cfg) + svc := service.New(repo, cfg) h := grpchandler.New(svc) go func() { diff --git a/rating/handler/api/http.go b/rating/handler/api/http.go index 9d66c41..134e0bf 100644 --- a/rating/handler/api/http.go +++ b/rating/handler/api/http.go @@ -4,19 +4,19 @@ import ( "encoding/json" "errors" "log" - "main/rating/controller" "main/rating/model" + "main/rating/service" "net/http" "strconv" ) // Handler defines a rating service controller. type Handler struct { - ctrl *controller.RatingService + ctrl *service.RatingService } // New creates a new rating service HTTP handler. -func New(ctrl *controller.RatingService) *Handler { +func New(ctrl *service.RatingService) *Handler { return &Handler{ ctrl: ctrl, } @@ -38,7 +38,7 @@ func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: v, err := h.ctrl.GetAggregatedRating(r.Context(), recordID, recordType) - if err != nil && errors.Is(err, controller.ErrNotFound) { + if err != nil && errors.Is(err, service.ErrNotFound) { w.WriteHeader(http.StatusNotFound) return } diff --git a/rating/handler/grpc/pb.go b/rating/handler/grpc/pb.go index 3e7086e..0b9cdaf 100644 --- a/rating/handler/grpc/pb.go +++ b/rating/handler/grpc/pb.go @@ -3,8 +3,8 @@ package grpc import ( "context" "errors" - "main/rating/controller" "main/rating/model" + "main/rating/service" "main/rpc" "google.golang.org/grpc/codes" @@ -14,11 +14,11 @@ import ( // Handler defines a gRPC rating API handler. type Handler struct { rpc.UnimplementedRatingServiceServer - svc *controller.RatingService + svc *service.RatingService } // New creates a new movie metadata gRPC handler. -func New(svc *controller.RatingService) *Handler { +func New(svc *service.RatingService) *Handler { return &Handler{ svc: svc, } @@ -31,7 +31,7 @@ func (h *Handler) GetAggregatedRating(ctx context.Context, req *rpc.GetAggregate } rating, err := h.svc.GetAggregatedRating(ctx, model.RecordID(req.RecordId), model.RecordType(req.RecordType)) - if err != nil && errors.Is(err, controller.ErrNotFound) { + if err != nil && errors.Is(err, service.ErrNotFound) { return nil, status.Errorf(codes.NotFound, err.Error()) } else if err != nil { return nil, status.Errorf(codes.Internal, err.Error()) diff --git a/rating/controller/rating.go b/rating/service/controller.go similarity index 99% rename from rating/controller/rating.go rename to rating/service/controller.go index 030f641..eb61560 100644 --- a/rating/controller/rating.go +++ b/rating/service/controller.go @@ -1,4 +1,4 @@ -package controller +package service import ( "context" diff --git a/rating/testutil/testutil.go b/rating/testutil/testutil.go new file mode 100644 index 0000000..a08f1fc --- /dev/null +++ b/rating/testutil/testutil.go @@ -0,0 +1,16 @@ +package testutil + +import ( + grpchandler "main/rating/handler/grpc" + "main/rating/repository/memory" + "main/rating/service" + "main/rpc" + "main/utils" +) + +// NewTestRatingGRPCServer creates a new rating gRPC server to be used in tests. +func NewTestRatingGRPCServer(cfg *utils.ConfigDatabase) rpc.RatingServiceServer { + r := memory.New() + svc := service.New(r, cfg) + return grpchandler.New(svc) +} diff --git a/sample.env b/sample.env index f8b8e14..5dd17da 100644 --- a/sample.env +++ b/sample.env @@ -6,3 +6,8 @@ SUBSCRIBER_NAME=rating-subscriber CONNECTION_TIMEOUT=30s OPERATION_TIMEOUT=30s DATABASE_URL=postgres://user:password@localhost:5432/moviedb?sslmode=disable +HOST=localhost +METADATA_PORT=8081 +RATING_PORT=8082 +MOVIE_PORT=8083 +CONSUL_URL=localhost:8500 \ No newline at end of file diff --git a/test/integration/main.go b/test/integration/main.go new file mode 100644 index 0000000..f89b148 --- /dev/null +++ b/test/integration/main.go @@ -0,0 +1,251 @@ +package main + +import ( + "context" + "flag" + "log" + "main/discovery" + "main/discovery/memory" + "main/rpc" + "main/utils" + "net" + + metadatatest "main/metadata/testutil" + movietest "main/movie/testutil" + ratingtest "main/rating/testutil" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + metadataServiceName = "metadata" + ratingServiceName = "rating" + movieServiceName = "movie" + + metadataServiceAddr = "localhost:8081" + ratingServiceAddr = "localhost:8082" + movieServiceAddr = "localhost:8083" +) + +func main() { + var config string + flag.StringVar(&config, "config", ".env", "Configuration path") + flag.Parse() + cfg := utils.LoadConfig(config) + + log.Println("Starting the integration test") + + ctx := context.Background() + registry := memory.NewRegistry() + + log.Println("Setting up service handlers and clients") + + metadataSrv := startMetadataService(ctx, registry) + defer metadataSrv.GracefulStop() + ratingSrv := startRatingService(ctx, registry, cfg) + defer ratingSrv.GracefulStop() + movieSrv := startMovieService(ctx, registry) + defer movieSrv.GracefulStop() + + opts := grpc.WithTransportCredentials(insecure.NewCredentials()) + metadataConn, err := grpc.Dial(metadataServiceAddr, opts) + if err != nil { + panic(err) + } + defer metadataConn.Close() + metadataClient := rpc.NewMetadataServiceClient(metadataConn) + + ratingConn, err := grpc.Dial(ratingServiceAddr, opts) + if err != nil { + panic(err) + } + defer ratingConn.Close() + ratingClient := rpc.NewRatingServiceClient(ratingConn) + + movieConn, err := grpc.Dial(movieServiceAddr, opts) + if err != nil { + panic(err) + } + defer movieConn.Close() + movieClient := rpc.NewMovieServiceClient(movieConn) + + log.Println("Saving test metadata via metadata service") + + m := &rpc.Metadata{ + MovieId: "the-movie", + Title: "The Movie", + Description: "The Movie, the one and only", + Director: "Mr. D", + } + + if _, err := metadataClient.PutMetadata(ctx, &rpc.PutMetadataRequest{ + Metadata: m, + }); err != nil { + log.Fatalf("put metadata: %v", err) + } + + log.Println("Retrieving test metadata via metadata service") + + getMetadataResp, err := metadataClient.GetMetadata(ctx, &rpc.GetMetadataRequest{MovieId: m.MovieId}) + if err != nil { + log.Fatalf("get metadata: %v", err) + } + if diff := cmp.Diff(getMetadataResp.Metadata, m, cmpopts.IgnoreUnexported(rpc.Metadata{})); diff != "" { + log.Fatalf("get metadata after put mismatch: %v", diff) + } + + log.Println("Getting movie details via movie service") + wantMovieDetails := &rpc.MovieDetails{ + Metadata: m, + } + + getMovieDetailsResp, err := movieClient.GetMovieDetails(ctx, &rpc.GetMovieDetailsRequest{MovieId: m.MovieId}) + if err != nil { + log.Fatalf("geet movie details: %v", err) + } + + if diff := cmp.Diff(getMovieDetailsResp.MovieDetails, wantMovieDetails, cmpopts.IgnoreUnexported(rpc.MovieDetails{}, rpc.Metadata{})); diff != "" { + log.Fatalf("get movie details after put mismatch: %v", err) + } + + log.Println("Saving first rating via rating service") + + const userID = "user0" + const recordTypeMovie = "movie" + firstRating := int32(5) + if _, err := ratingClient.PutRating(ctx, &rpc.PutRatingRequest{ + UserId: userID, + RecordId: m.MovieId, + RecordType: recordTypeMovie, + RatingValue: firstRating, + }); err != nil { + log.Fatalf("put rating: %v", err) + } + + log.Println("Retrieving initial aggregated rating via rating service") + + getAggregatedRatingResp, err := ratingClient.GetAggregatedRating(ctx, &rpc.GetAggregatedRatingRequest{ + RecordId: m.MovieId, + RecordType: recordTypeMovie, + }) + if err != nil { + log.Fatalf("get aggregated rating: %v", err) + } + + if got, want := getAggregatedRatingResp.RatingValue, float64(5); got != want { + log.Fatalf("rating mismatch: got %v want %v", got, want) + } + + log.Println("Saving second rating via rating service") + secondRating := int32(1) + if _, err := ratingClient.PutRating(ctx, &rpc.PutRatingRequest{ + UserId: userID, + RecordId: m.MovieId, + RecordType: recordTypeMovie, + RatingValue: secondRating, + }); err != nil { + log.Fatalf("put rating: %v", err) + } + + log.Println("Saving new aggregated rating via rating service") + getAggregatedRatingResp, err = ratingClient.GetAggregatedRating(ctx, &rpc.GetAggregatedRatingRequest{ + RecordId: m.MovieId, + RecordType: recordTypeMovie, + }) + if err != nil { + log.Fatalf("get aggregated rating: %v", err) + } + + wantRating := float64((firstRating + secondRating) / 2) + if got, want := getAggregatedRatingResp.RatingValue, wantRating; got != want { + log.Fatalf("rating mismatch: got %v want %v", got, want) + } + + log.Println("Getting updated movie details via movie service") + + getMovieDetailsResp, err = movieClient.GetMovieDetails(ctx, &rpc.GetMovieDetailsRequest{MovieId: m.MovieId}) + if err != nil { + log.Fatalf("get movie details: %v", err) + } + + wantMovieDetails.Rating = wantRating + if diff := cmp.Diff(getMovieDetailsResp.MovieDetails, wantMovieDetails, cmpopts.IgnoreUnexported(rpc.MovieDetails{}, rpc.Metadata{})); diff != "" { + log.Fatalf("get movie details after update mismatch: %v", err) + } + + log.Println("Integration test execution successfull") +} + +func startMetadataService(ctx context.Context, registry discovery.Registry) *grpc.Server { + log.Println("Starting metadata service on ", metadataServiceAddr) + h := metadatatest.NewTestMetadataGRPCServer() + l, err := net.Listen("tcp", metadataServiceAddr) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + + srv := grpc.NewServer() + rpc.RegisterMetadataServiceServer(srv, h) + go func() { + if err := srv.Serve(l); err != nil { + panic(err) + } + }() + + id := discovery.GenerateInstanceID(metadataServiceName) + if err := registry.Register(ctx, id, metadataServiceName, metadataServiceAddr); err != nil { + panic(err) + } + + return srv +} + +func startRatingService(ctx context.Context, registry discovery.Registry, cfg *utils.ConfigDatabase) *grpc.Server { + log.Println("Starting rating service on ", ratingServiceAddr) + h := ratingtest.NewTestRatingGRPCServer(cfg) + l, err := net.Listen("tcp", ratingServiceAddr) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + srv := grpc.NewServer() + rpc.RegisterRatingServiceServer(srv, h) + go func() { + if err := srv.Serve(l); err != nil { + panic(err) + } + }() + + id := discovery.GenerateInstanceID(ratingServiceName) + if err := registry.Register(ctx, id, ratingServiceName, ratingServiceAddr); err != nil { + panic(err) + } + + return srv +} + +func startMovieService(ctx context.Context, registry discovery.Registry) *grpc.Server { + log.Println("Starting movie service on ", movieServiceAddr) + h := movietest.NewTestMovieGRPCServer(registry) + l, err := net.Listen("tcp", movieServiceAddr) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + srv := grpc.NewServer() + rpc.RegisterMovieServiceServer(srv, h) + + go func() { + if err := srv.Serve(l); err != nil { + panic(err) + } + }() + + id := discovery.GenerateInstanceID(movieServiceName) + if err := registry.Register(ctx, id, movieServiceName, movieServiceAddr); err != nil { + panic(err) + } + + return srv +} diff --git a/utils/grpcutil.go b/utils/grpcutil.go index 0476493..ca20db4 100644 --- a/utils/grpcutil.go +++ b/utils/grpcutil.go @@ -2,10 +2,8 @@ package utils import ( "context" - "fmt" "main/discovery" "math/rand" - "strings" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -19,7 +17,7 @@ func ServiceConnection(ctx context.Context, serviceName string, registry discove } targetAddress := addrs[rand.Intn(len(addrs))] - fmt.Printf("%s: %s\n", strings.ToUpper(serviceName), targetAddress) + // fmt.Printf("%s: %s\n", strings.ToUpper(serviceName), targetAddress) return grpc.Dial(targetAddress, grpc.WithTransportCredentials(insecure.NewCredentials())) } diff --git a/utils/random.go b/utils/random.go new file mode 100644 index 0000000..390be71 --- /dev/null +++ b/utils/random.go @@ -0,0 +1,33 @@ +package utils + +import ( + "math/rand" + "strings" + "time" +) + +var random *rand.Rand + +const alphabet = "abcdefghijklmnopqrstuvwxyz" + +func init() { + random = rand.New(rand.NewSource(time.Now().UnixNano())) +} + +// RandomString generates a random string of length n +func RandomString(n int) string { + var sb strings.Builder + k := len(alphabet) + + for i := 0; i < n; i++ { + c := alphabet[random.Intn(k)] + sb.WriteByte(c) + } + + return sb.String() +} + +// RandomInt generates a random integer between min and max +func RandomInt(min, max int64) int64 { + return min + random.Int63n(max-min+1) +}