diff --git a/.mockery.yaml b/.mockery.yaml index 1a0a011..4716301 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -1,25 +1,24 @@ dir: "{{.InterfaceDirRelative}}" inpackage: true filename: "mock_{{.InterfaceNameSnake}}.go" +mockname: "mock{{ .InterfaceName | camelcase }}" mock-build-tags: "!release" disable-version-string: true packages: - word-of-wisdom-go/pkg/api/tcp/commands: + word-of-wisdom-go/internal/app: + config: + filename: "{{.InterfaceNameSnake}}.go" + mockname: "{{ .InterfaceName | camelcase }}" interfaces: - CommandHandler: - word-of-wisdom-go/pkg/app/challenges: - interfaces: - RequestRateMonitor: - Challenges: - word-of-wisdom-go/pkg/app/wow: - interfaces: - Query: + mockRequestRateMonitor: + mockChallenges: + mockWowQuery: log/slog: interfaces: Handler: config: inpackage: false outpkg: 'diag' - dir: 'pkg/diag' + dir: 'internal/diag' filename: "mock_slog_handler.go" mockname: MockSlogHandler \ No newline at end of file diff --git a/Makefile b/Makefile index 3a86b44..fd0ec9b 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ $(go-test-coverage): .PHONY: $(cover_profile) $(cover_profile): $(cover_dir) - TZ=US/Alaska go test -timeout 30s -shuffle=on -failfast -coverpkg=./pkg/...,./cmd/... -coverprofile=$(cover_profile) -covermode=atomic ./... + TZ=US/Alaska go test -shuffle=on -failfast -coverpkg=./internal/...,./cmd/... -coverprofile=$(cover_profile) -covermode=atomic ./... test: $(go-test-coverage) $(cover_profile) go tool cover -html=$(cover_profile) -o $(cover_html) @@ -45,12 +45,15 @@ test: $(go-test-coverage) $(cover_profile) docker-images: make -C docker clean-images .local-client-image .local-server-image -$(cover_dir)/coverage.%.blob-sha: - @gh api \ +$(cover_dir)/repo-name-with-owner.txt: + gh repo view --json nameWithOwner -q .nameWithOwner > $@ + +$(cover_dir)/coverage.%.blob-sha: $(cover_dir)/repo-name-with-owner.txt + gh api \ --method GET \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/gemyago/word-of-wisdom-go/contents/coverage/golang-coverage.$*?ref=test-artifacts \ + /repos/$(shell cat $(cover_dir)/repo-name-with-owner.txt)/contents/coverage/golang-coverage.$*?ref=test-artifacts \ | jq -jr '.sha' > $@ $(cover_dir)/coverage.%.gh-cli-body.json: $(cover_dir)/coverage.% $(cover_dir)/coverage.%.blob-sha @@ -64,7 +67,6 @@ $(cover_dir)/coverage.%.gh-cli-body.json: $(cover_dir)/coverage.% $(cover_dir)/c @base64 -i $< | tr -d '\n' >> $@ @printf "\"\n}">> $@ - # Orphan branch will need to be created prior to running this # git checkout --orphan test-artifacts # git rm -rf . @@ -74,16 +76,16 @@ $(cover_dir)/coverage.%.gh-cli-body.json: $(cover_dir)/coverage.% $(cover_dir)/c # git commit -m 'init' # git push origin test-artifacts .PHONY: push-test-artifacts -push-test-artifacts: $(cover_dir)/coverage.svg.gh-cli-body.json $(cover_dir)/coverage.html.gh-cli-body.json +push-test-artifacts: $(cover_dir)/coverage.svg.gh-cli-body.json $(cover_dir)/coverage.html.gh-cli-body.json $(cover_dir)/repo-name-with-owner.txt @gh api \ --method PUT \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/gemyago/word-of-wisdom-go/contents/coverage/golang-coverage.svg \ + /repos/$(shell cat $(cover_dir)/repo-name-with-owner.txt)/contents/coverage/golang-coverage.svg \ --input $(cover_dir)/coverage.svg.gh-cli-body.json @gh api \ --method PUT \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/gemyago/word-of-wisdom-go/contents/coverage/golang-coverage.html \ + /repos/$(shell cat $(cover_dir)/repo-name-with-owner.txt)/contents/coverage/golang-coverage.html \ --input $(cover_dir)/coverage.html.gh-cli-body.json \ No newline at end of file diff --git a/cmd/client/client.go b/cmd/client/client.go index d1e0b98..55635a6 100644 --- a/cmd/client/client.go +++ b/cmd/client/client.go @@ -7,7 +7,7 @@ import ( "log/slog" "os" "time" - "word-of-wisdom-go/pkg/diag" + "word-of-wisdom-go/internal/diag" "github.com/spf13/cobra" "go.uber.org/dig" @@ -30,6 +30,15 @@ type runWOWCommandParams struct { output io.Writer } +func writeLines(w io.Writer, lines ...string) error { + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + return fmt.Errorf("failed to write line: %w", err) + } + } + return nil +} + func runWOWCommand(ctx context.Context, params runWOWCommandParams) error { ctx, cancel := context.WithDeadline(ctx, time.Now().Add(params.MaxSessionDuration)) defer cancel() @@ -50,9 +59,10 @@ func runWOWCommand(ctx context.Context, params runWOWCommandParams) error { if err != nil { return err } - fmt.Fprintln(params.output, "Your Word of Wisdom for today:") - fmt.Fprintln(params.output, result) - return nil + return writeLines(params.output, + "Your Word of Wisdom for today:", + result, + ) } func newClientCmd(container *dig.Container) *cobra.Command { diff --git a/cmd/client/client_test.go b/cmd/client/client_test.go index 5642eab..87720d9 100644 --- a/cmd/client/client_test.go +++ b/cmd/client/client_test.go @@ -5,8 +5,8 @@ import ( "context" "errors" "testing" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services/networking" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" "github.com/go-faker/faker/v4" "github.com/stretchr/testify/assert" @@ -17,20 +17,20 @@ func TestClient(t *testing.T) { t.Run("runWOWCommand", func(t *testing.T) { t.Run("should process wow command", func(t *testing.T) { ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() wantWow := faker.Sentence() var output bytes.Buffer wantAddress := faker.Word() params := runWOWCommandParams{ serverAddress: wantAddress, RootLogger: diag.RootTestLogger(), - SessionDialer: sessionDialerFunc(func(network, address string) (networking.Session, func() error, error) { + SessionDialer: sessionDialerFunc(func(network, address string) (*services.SessionIO, func() error, error) { assert.Equal(t, "tcp", network) assert.Equal(t, wantAddress, address) - return mockSession, func() error { return nil }, nil + return ctrl.Session, func() error { return nil }, nil }), - WOWCommand: WOWCommandFunc(func(_ context.Context, session networking.Session) (string, error) { - assert.Equal(t, mockSession, session) + WOWCommand: WOWCommandFunc(func(_ context.Context, session *services.SessionIO) (string, error) { + assert.Equal(t, ctrl.Session, session) return wantWow, nil }), output: &output, @@ -45,7 +45,7 @@ func TestClient(t *testing.T) { params := runWOWCommandParams{ serverAddress: wantAddress, RootLogger: diag.RootTestLogger(), - SessionDialer: sessionDialerFunc(func(_, _ string) (networking.Session, func() error, error) { + SessionDialer: sessionDialerFunc(func(_, _ string) (*services.SessionIO, func() error, error) { return nil, nil, wantDialErr }), } @@ -57,10 +57,10 @@ func TestClient(t *testing.T) { params := runWOWCommandParams{ serverAddress: faker.Word(), RootLogger: diag.RootTestLogger(), - SessionDialer: sessionDialerFunc(func(_, _ string) (networking.Session, func() error, error) { - return networking.NewMockSession(), func() error { return nil }, nil + SessionDialer: sessionDialerFunc(func(_, _ string) (*services.SessionIO, func() error, error) { + return services.NewMockSessionIOController().Session, func() error { return nil }, nil }), - WOWCommand: WOWCommandFunc(func(_ context.Context, _ networking.Session) (string, error) { + WOWCommand: WOWCommandFunc(func(_ context.Context, _ *services.SessionIO) (string, error) { return "", wantErr }), } @@ -71,10 +71,10 @@ func TestClient(t *testing.T) { params := runWOWCommandParams{ serverAddress: faker.Word(), RootLogger: diag.RootTestLogger(), - SessionDialer: sessionDialerFunc(func(_, _ string) (networking.Session, func() error, error) { - return networking.NewMockSession(), func() error { return errors.New(faker.Sentence()) }, nil + SessionDialer: sessionDialerFunc(func(_, _ string) (*services.SessionIO, func() error, error) { + return services.NewMockSessionIOController().Session, func() error { return errors.New(faker.Sentence()) }, nil }), - WOWCommand: WOWCommandFunc(func(_ context.Context, _ networking.Session) (string, error) { + WOWCommand: WOWCommandFunc(func(_ context.Context, _ *services.SessionIO) (string, error) { return faker.Sentence(), nil }), output: &bytes.Buffer{}, diff --git a/cmd/client/dialer.go b/cmd/client/dialer.go index bf2959a..91d1d50 100644 --- a/cmd/client/dialer.go +++ b/cmd/client/dialer.go @@ -4,19 +4,19 @@ import ( "fmt" "net" "time" - "word-of-wisdom-go/pkg/services/networking" + "word-of-wisdom-go/internal/services" "go.uber.org/dig" ) type SessionDialer interface { // DialSession establishes new connection and returns session and close function - DialSession(network, address string) (networking.Session, func() error, error) + DialSession(network, address string) (*services.SessionIO, func() error, error) } -type sessionDialerFunc func(network, address string) (networking.Session, func() error, error) +type sessionDialerFunc func(network, address string) (*services.SessionIO, func() error, error) -func (f sessionDialerFunc) DialSession(network, address string) (networking.Session, func() error, error) { +func (f sessionDialerFunc) DialSession(network, address string) (*services.SessionIO, func() error, error) { return f(network, address) } @@ -30,7 +30,7 @@ type SessionDialerDeps struct { } func newSessionDialer(deps SessionDialerDeps) SessionDialer { - return sessionDialerFunc(func(network, address string) (networking.Session, func() error, error) { + return sessionDialerFunc(func(network, address string) (*services.SessionIO, func() error, error) { conn, err := net.Dial(network, address) if err != nil { return nil, nil, fmt.Errorf("error connecting to server: %w", err) @@ -38,7 +38,7 @@ func newSessionDialer(deps SessionDialerDeps) SessionDialer { if err = conn.SetDeadline(time.Now().Add(deps.IOTimeout)); err != nil { // coverage-ignore // hard to simulate this return nil, nil, fmt.Errorf("failed to set deadline: %w", err) } - session := networking.NewSession(conn.LocalAddr().String(), conn) + session := services.NewSessionIO(conn.LocalAddr().String(), conn) return session, conn.Close, nil }) } diff --git a/cmd/client/root.go b/cmd/client/root.go index 49b29ce..93a4f72 100644 --- a/cmd/client/root.go +++ b/cmd/client/root.go @@ -4,11 +4,11 @@ import ( "errors" "fmt" "log/slog" - "word-of-wisdom-go/config" - "word-of-wisdom-go/pkg/app/challenges" - "word-of-wisdom-go/pkg/di" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/app" + "word-of-wisdom-go/internal/config" + "word-of-wisdom-go/internal/di" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" "github.com/spf13/cobra" "go.uber.org/dig" @@ -71,14 +71,15 @@ func newRootCmd(container *dig.Container) *cobra.Command { di.ProvideAll(container, di.ProvideValue(rootLogger), - // app layer - challenges.NewChallenges, - - // client deps + // client specific deps newSessionDialer, newWOWCommand, + di.ProvideAs[*app.Challenges, challengesService], ), + // app layer + app.Register(container), + // service layer services.Register(container), ) diff --git a/cmd/client/solve_challenge.go b/cmd/client/solve_challenge.go index ede0e64..110ff6b 100644 --- a/cmd/client/solve_challenge.go +++ b/cmd/client/solve_challenge.go @@ -7,7 +7,6 @@ import ( "log/slog" "os" "time" - "word-of-wisdom-go/pkg/app/challenges" "github.com/samber/lo" "github.com/spf13/cobra" @@ -19,7 +18,7 @@ type runSolveChallengeCommandParams struct { RootLogger *slog.Logger - challenges.Challenges + Challenges challengesService // client params challengeToSolve string diff --git a/cmd/client/wow.go b/cmd/client/wow.go index 69cc9d9..f76b6f3 100644 --- a/cmd/client/wow.go +++ b/cmd/client/wow.go @@ -7,39 +7,43 @@ import ( "strconv" "strings" "time" - "word-of-wisdom-go/pkg/app/challenges" - "word-of-wisdom-go/pkg/services/networking" + "word-of-wisdom-go/internal/api/tcp/commands" + "word-of-wisdom-go/internal/services" "go.uber.org/dig" ) type WOWCommand interface { - Process(ctx context.Context, session networking.Session) (string, error) + Process(ctx context.Context, session *services.SessionIO) (string, error) } -type WOWCommandFunc func(ctx context.Context, session networking.Session) (string, error) +type WOWCommandFunc func(ctx context.Context, session *services.SessionIO) (string, error) -func (f WOWCommandFunc) Process(ctx context.Context, session networking.Session) (string, error) { +func (f WOWCommandFunc) Process(ctx context.Context, session *services.SessionIO) (string, error) { return f(ctx, session) } var _ WOWCommandFunc = WOWCommandFunc(nil) +type challengesService interface { + SolveChallenge(ctx context.Context, complexity int, challenge string) (string, error) +} + type WOWCommandDeps struct { dig.In RootLogger *slog.Logger // app layer - challenges.Challenges + Challenges challengesService } func newWOWCommand(deps WOWCommandDeps) WOWCommand { logger := deps.RootLogger.WithGroup("client") - return WOWCommandFunc(func(ctx context.Context, session networking.Session) (string, error) { + return WOWCommandFunc(func(ctx context.Context, session *services.SessionIO) (string, error) { logger.DebugContext(ctx, "Sending GET_WOW request") - if err := session.WriteLine("GET_WOW"); err != nil { + if err := session.WriteLine(commands.CommandGetWow); err != nil { return "", fmt.Errorf("failed to write to the server: %w", err) } @@ -50,18 +54,18 @@ func newWOWCommand(deps WOWCommandDeps) WOWCommand { logger.DebugContext(ctx, "Got response", slog.String("data", line)) - if strings.Index(line, "WOW:") == 0 { + if strings.Index(line, commands.WowResponsePrefix) == 0 { logger.DebugContext(ctx, "Got WOW response. No challenge required") return strings.Trim(line[4:], " "), nil } - if strings.Index(line, "CHALLENGE_REQUIRED:") != 0 { + if strings.Index(line, commands.ChallengeRequiredPrefix) != 0 { return "", fmt.Errorf("got unexpected challenge requirement response %s: %w", line, err) } separatorIndex := strings.Index(line, ";") - challenge := strings.Trim(line[len("CHALLENGE_REQUIRED:"):separatorIndex], " ") + challenge := strings.Trim(line[len(commands.ChallengeRequiredPrefix):separatorIndex], " ") complexity, err := strconv.Atoi(line[separatorIndex+1:]) if err != nil { return "", err @@ -79,7 +83,7 @@ func newWOWCommand(deps WOWCommandDeps) WOWCommand { slog.Duration("solutionDuration", time.Since(solveStartedAt)), slog.String("solution", solution), ) - if err = session.WriteLine("CHALLENGE_RESULT: " + solution); err != nil { + if err = session.WriteLine(commands.ChallengeResultPrefix + solution); err != nil { return "", fmt.Errorf("failed to write to the server: %w", err) } @@ -90,7 +94,7 @@ func newWOWCommand(deps WOWCommandDeps) WOWCommand { logger.DebugContext(ctx, "Got response", slog.String("data", line)) - if strings.Index(line, "WOW:") == 0 { + if strings.Index(line, commands.WowResponsePrefix) == 0 { return strings.Trim(line[4:], " "), nil } diff --git a/cmd/client/wow_test.go b/cmd/client/wow_test.go index df27738..c872d18 100644 --- a/cmd/client/wow_test.go +++ b/cmd/client/wow_test.go @@ -6,9 +6,9 @@ import ( "math/rand/v2" "strconv" "testing" - "word-of-wisdom-go/pkg/app/challenges" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services/networking" + "word-of-wisdom-go/internal/app" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" "github.com/go-faker/faker/v4" "github.com/samber/lo" @@ -21,7 +21,7 @@ func TestWow(t *testing.T) { newMockDeps := func(t *testing.T) WOWCommandDeps { return WOWCommandDeps{ RootLogger: diag.RootTestLogger(), - Challenges: challenges.NewMockChallenges(t), + Challenges: app.NewMockChallenges(t), } } @@ -30,16 +30,16 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) wantWow := faker.Sentence() go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - mockSession.MockSendLine("WOW: " + wantWow) + ctrl.MockSendLine("WOW: " + wantWow) cmdRes := <-cmdResCh require.NoError(t, cmdRes.B) @@ -51,18 +51,18 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) wantWow := faker.Sentence() wantErr := errors.New(faker.Sentence()) - mockSession.MockSetNextError(wantErr) + ctrl.MockSetNextReadError(wantErr) go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - mockSession.MockSendLine("WOW: " + wantWow) + ctrl.MockSendLine("WOW: " + wantWow) cmdRes := <-cmdResCh assert.ErrorIs(t, cmdRes.B, wantErr) @@ -73,27 +73,27 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) wantWow := faker.Sentence() wantChallenge := faker.Sentence() wantComplexity := rand.IntN(10) wantSolution := faker.Sentence() - mockChallenges, _ := deps.Challenges.(*challenges.MockChallenges) + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) mockChallenges.EXPECT().SolveChallenge(ctx, wantComplexity, wantChallenge).Return(wantSolution, nil) go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - gotSolutionResult := mockSession.MockSendLineAndWaitResult( + gotSolutionResult := ctrl.MockSendLineAndWaitResult( "CHALLENGE_REQUIRED: " + wantChallenge + ";" + strconv.Itoa(wantComplexity), ) assert.Equal(t, "CHALLENGE_RESULT: "+wantSolution, gotSolutionResult) - mockSession.MockSendLine("WOW: " + wantWow) + ctrl.MockSendLine("WOW: " + wantWow) cmdRes := <-cmdResCh require.NoError(t, cmdRes.B) @@ -105,16 +105,16 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - mockSession.MockSendLine(faker.Word()) + ctrl.MockSendLine(faker.Word()) cmdRes := <-cmdResCh assert.ErrorContains(t, cmdRes.B, "unexpected challenge requirement") }) @@ -124,22 +124,22 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) - mockChallenges, _ := deps.Challenges.(*challenges.MockChallenges) + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) mockChallenges.EXPECT().SolveChallenge(ctx, mock.Anything, mock.Anything).Return(faker.Sentence(), nil) go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - mockSession.MockSendLineAndWaitResult( + ctrl.MockSendLineAndWaitResult( "CHALLENGE_REQUIRED: " + faker.Word() + ";" + strconv.Itoa(rand.Int()), ) - mockSession.MockSendLine(faker.Word()) + ctrl.MockSendLine(faker.Word()) cmdRes := <-cmdResCh assert.ErrorContains(t, cmdRes.B, "got unexpected WOW response") @@ -150,16 +150,16 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - mockSession.MockSendLine( + ctrl.MockSendLine( "CHALLENGE_REQUIRED: " + faker.Word() + ";" + faker.Word(), ) @@ -172,20 +172,20 @@ func TestWow(t *testing.T) { cmd := newWOWCommand(deps) ctx := context.Background() - mockSession := networking.NewMockSession() + ctrl := services.NewMockSessionIOController() cmdResCh := make(chan lo.Tuple2[string, error]) - mockChallenges, _ := deps.Challenges.(*challenges.MockChallenges) + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) wantErr := errors.New(faker.Sentence()) mockChallenges.EXPECT().SolveChallenge(ctx, mock.Anything, mock.Anything).Return("", wantErr) go func() { - res, err := cmd.Process(ctx, mockSession) + res, err := cmd.Process(ctx, ctrl.Session) cmdResCh <- lo.Tuple2[string, error]{A: res, B: err} }() - gotCmd := mockSession.MockWaitResult() + gotCmd := ctrl.MockWaitResult() assert.Equal(t, "GET_WOW", gotCmd) - mockSession.MockSendLine( + ctrl.MockSendLine( "CHALLENGE_REQUIRED: " + faker.Word() + ";" + strconv.Itoa(rand.Int()), ) diff --git a/cmd/server/root.go b/cmd/server/root.go index 89c96bf..9d6835f 100644 --- a/cmd/server/root.go +++ b/cmd/server/root.go @@ -4,14 +4,13 @@ import ( "errors" "fmt" "log/slog" - "word-of-wisdom-go/config" - "word-of-wisdom-go/pkg/api/tcp/commands" - "word-of-wisdom-go/pkg/api/tcp/server" - "word-of-wisdom-go/pkg/app/challenges" - "word-of-wisdom-go/pkg/app/wow" - "word-of-wisdom-go/pkg/di" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/api/tcp/commands" + "word-of-wisdom-go/internal/api/tcp/server" + "word-of-wisdom-go/internal/app" + "word-of-wisdom-go/internal/config" + "word-of-wisdom-go/internal/di" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" "github.com/spf13/cobra" "go.uber.org/dig" @@ -72,16 +71,15 @@ func newRootCmd(container *dig.Container) *cobra.Command { config.Provide(container, cfg), di.ProvideAll(container, di.ProvideValue(rootLogger), + ), - // api layer - commands.NewHandler, - server.NewListener, + // api layer + commands.Register(container), + server.Register(container), + + // app layer + app.Register(container), - // app layer - challenges.NewChallenges, - challenges.NewRequestRateMonitor, - wow.NewQuery, - ), // services services.Register(container), ) diff --git a/cmd/server/tcp_server.go b/cmd/server/tcp_server.go index 67f4b44..2def3b1 100644 --- a/cmd/server/tcp_server.go +++ b/cmd/server/tcp_server.go @@ -5,8 +5,8 @@ import ( "log/slog" "os/signal" "time" - "word-of-wisdom-go/pkg/api/tcp/server" - "word-of-wisdom-go/pkg/diag" + "word-of-wisdom-go/internal/api/tcp/server" + "word-of-wisdom-go/internal/diag" "github.com/spf13/cobra" "go.uber.org/dig" diff --git a/config/local.json b/config/local.json deleted file mode 100644 index f98220d..0000000 --- a/config/local.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "defaultLogLevel": "INFO" -} \ No newline at end of file diff --git a/config/test.json b/config/test.json deleted file mode 100644 index 7a73a41..0000000 --- a/config/test.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} \ No newline at end of file diff --git a/go.mod b/go.mod index 6288d32..4dbb288 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/stretchr/testify v1.9.0 github.com/vektra/mockery/v2 v2.45.0 go.uber.org/dig v1.18.0 - golang.org/x/sys v0.25.0 + golang.org/x/sys v0.26.0 ) require ( @@ -40,12 +40,12 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/term v0.5.0 // indirect - golang.org/x/text v0.18.0 // indirect - golang.org/x/tools v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/tools v0.26.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 888b03d..ba696d4 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 h1:1wqE9dj9NpSm04INVsJhhEUzhuDVjbcyKH91sVyPATw= +golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= @@ -97,12 +99,18 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/tcp/commands/handler.go b/internal/api/tcp/commands/handler.go new file mode 100644 index 0000000..bdacc6d --- /dev/null +++ b/internal/api/tcp/commands/handler.go @@ -0,0 +1,152 @@ +package commands + +import ( + "context" + "fmt" + "log/slog" + "strings" + "word-of-wisdom-go/internal/app" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" + + "go.uber.org/dig" +) + +// protocol related constants. +const ( + CommandGetWow = "GET_WOW" + WowResponsePrefix = "WOW: " + challengeRequired = "CHALLENGE_REQUIRED" + ChallengeRequiredPrefix = challengeRequired + ": " + ChallengeResultPrefix = "CHALLENGE_RESULT: " + + errBadCmdResponse = "ERR: BAD_CMD" + errInternalError = "ERR: INTERNAL_ERROR" + errUnexpectedChallengeResult = "ERR: UNEXPECTED_CHALLENGE_RESULT" + errChallengeVerificationFail = "ERR: CHALLENGE_VERIFICATION_FAILED" +) + +type wowQuery interface { + GetNextWoW(_ context.Context) (string, error) +} + +type requestRateMonitor interface { + RecordRequest(ctx context.Context, clientID string) (app.RecordRequestResult, error) +} + +type challengesService interface { + GenerateNewChallenge(clientID string) (string, error) + VerifySolution(complexity int, challenge, solution string) bool +} + +type CommandHandlerDeps struct { + dig.In + + RootLogger *slog.Logger + + // components + RequestRateMonitor requestRateMonitor + Challenges challengesService + Query wowQuery +} + +type CommandHandler struct { + deps CommandHandlerDeps + logger *slog.Logger +} + +func (h *CommandHandler) trace(ctx context.Context, msg string, args ...any) { + h.logger.DebugContext(ctx, msg, args...) +} + +func (h *CommandHandler) performChallengeVerification( + ctx context.Context, + session *services.SessionIO, + monitoringResult app.RecordRequestResult, +) (bool, error) { + var challenge string + challenge, err := h.deps.Challenges.GenerateNewChallenge(session.ClientID()) + if err != nil { + return false, fmt.Errorf("failed to generate new challenge: %w", err) + } + + h.trace(ctx, "Requiring to solve challenge", slog.Int("complexity", monitoringResult.ChallengeComplexity)) + challengeData := fmt.Sprintf( + "%s%s;%d", + ChallengeRequiredPrefix, challenge, monitoringResult.ChallengeComplexity) + if err = session.WriteLine(challengeData); err != nil { + return false, fmt.Errorf("failed to send challenge: %w", err) + } + var cmd string + if cmd, err = session.ReadLine(); err != nil { + return false, fmt.Errorf("failed to read challenge result: %w", err) + } + if strings.Index(cmd, ChallengeResultPrefix) != 0 { + h.trace(ctx, "Got unexpected challenge result", slog.String("data", cmd)) + return false, session.WriteLine(errUnexpectedChallengeResult) + } + + if !h.deps.Challenges.VerifySolution( + monitoringResult.ChallengeComplexity, + challenge, + strings.Trim(cmd[len(ChallengeResultPrefix):], " "), + ) { + h.trace(ctx, "Challenge verification failed", slog.String("data", cmd)) + return false, session.WriteLine(errChallengeVerificationFail) + } + return true, nil +} + +func (h *CommandHandler) Handle(ctx context.Context, session *services.SessionIO) error { + cmd, err := session.ReadLine() + if err != nil { + return fmt.Errorf("failed to read command: %w", err) + } + + // If we need to extend it to support multiple commands + // then this will need to be refactored roughly as follows: + // - new Commands component is added that implement all various commands + // - the HandleCommands will read the command from the connection, and forward + // the processing to particular command implementation + // Keeping it simple for now since we need just a single command. + if cmd != CommandGetWow { + h.trace(ctx, "Got bad command", slog.String("cmd", cmd)) + return session.WriteLine(errBadCmdResponse) + } + + monitoringResult, err := h.deps.RequestRateMonitor.RecordRequest(ctx, session.ClientID()) + if err != nil { + h.logger.ErrorContext(ctx, + "Failed to record request", + slog.String("clientID", session.ClientID()), + diag.ErrAttr(err), + ) + return session.WriteLine(errInternalError) + } + + if monitoringResult.ChallengeRequired { + var ok bool + ok, err = h.performChallengeVerification(ctx, session, monitoringResult) + if err != nil { + return err + } + if !ok { + return nil + } + } + + wow, err := h.deps.Query.GetNextWoW(ctx) + if err != nil { + return fmt.Errorf("failed to get next wow: %w", err) + } + + h.trace(ctx, "Responding with WOW") + return session.WriteLine(WowResponsePrefix + wow) +} + +func NewHandler(deps CommandHandlerDeps) *CommandHandler { + return &CommandHandler{ + deps: deps, + logger: deps.RootLogger.WithGroup("tcp.server.handler"), + } +} diff --git a/internal/api/tcp/commands/handler_test.go b/internal/api/tcp/commands/handler_test.go new file mode 100644 index 0000000..82754f1 --- /dev/null +++ b/internal/api/tcp/commands/handler_test.go @@ -0,0 +1,265 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "testing" + "word-of-wisdom-go/internal/app" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" + + "github.com/go-faker/faker/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCommands(t *testing.T) { + makeMockDeps := func(t *testing.T) CommandHandlerDeps { + return CommandHandlerDeps{ + RootLogger: diag.RootTestLogger(), + RequestRateMonitor: app.NewMockRequestRateMonitor(t), + Challenges: app.NewMockChallenges(t), + Query: app.NewMockWowQuery(t), + } + } + + t.Run("Handle", func(t *testing.T) { + t.Run("should fail if err getting command", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + wantErr := errors.New(faker.Sentence()) + ctrl := services.NewMockSessionIOController() + ctrl.MockSetNextReadError(wantErr) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + ctrl.MockSendLine(faker.Word()) + assert.ErrorIs(t, <-handleErr, wantErr) + }) + t.Run("should fail if unexpected command", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + result := ctrl.MockSendLineAndWaitResult(faker.Word()) + assert.Equal(t, "ERR: BAD_CMD", result) + assert.NoError(t, <-handleErr) + }) + t.Run("should process GET_WOW if no challenge required", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + app.RecordRequestResult{}, nil, + ) + + wantWow := faker.Sentence() + mockQuery, _ := deps.Query.(*app.MockWowQuery) + mockQuery.EXPECT().GetNextWoW(ctx).Return(wantWow, nil) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + result := ctrl.MockSendLineAndWaitResult("GET_WOW") + assert.Equal(t, "WOW: "+wantWow, result) + assert.NoError(t, <-handleErr) + }) + t.Run("should process GET_WOW with challenge required", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + monitorResult := app.RecordRequestResult{ + ChallengeRequired: true, + ChallengeComplexity: 5 + rand.IntN(10), + } + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + monitorResult, nil, + ) + + wantChallenge := faker.UUIDHyphenated() + wantSolution := faker.UUIDHyphenated() + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) + mockChallenges.EXPECT().GenerateNewChallenge(ctrl.Session.ClientID()).Return(wantChallenge, nil) + mockChallenges.EXPECT().VerifySolution( + monitorResult.ChallengeComplexity, + wantChallenge, + wantSolution, + ).Return(true) + + wantWow := faker.Sentence() + mockQuery, _ := deps.Query.(*app.MockWowQuery) + mockQuery.EXPECT().GetNextWoW(ctx).Return(wantWow, nil) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + result := ctrl.MockSendLineAndWaitResult("GET_WOW") + assert.Equal(t, fmt.Sprintf("CHALLENGE_REQUIRED: %s;%d", wantChallenge, monitorResult.ChallengeComplexity), result) + + result = ctrl.MockSendLineAndWaitResult("CHALLENGE_RESULT: " + wantSolution) + assert.Equal(t, "WOW: "+wantWow, result) + assert.NoError(t, <-handleErr) + }) + t.Run("should fail if record request error", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + app.RecordRequestResult{}, errors.New(faker.Sentence()), + ) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + result := ctrl.MockSendLineAndWaitResult("GET_WOW") + assert.Equal(t, "ERR: INTERNAL_ERROR", result) + assert.NoError(t, <-handleErr) + }) + t.Run("should handle get next wow query error", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + app.RecordRequestResult{}, nil, + ) + + mockQuery, _ := deps.Query.(*app.MockWowQuery) + wantErr := errors.New(faker.Sentence()) + mockQuery.EXPECT().GetNextWoW(ctx).Return("", wantErr) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + ctrl.MockSendLine("GET_WOW") + assert.ErrorIs(t, <-handleErr, wantErr) + }) + t.Run("should handle challenge generation errors", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + app.RecordRequestResult{ChallengeRequired: true}, nil, + ) + + wantErr := errors.New(faker.Sentence()) + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) + mockChallenges.EXPECT().GenerateNewChallenge(ctrl.Session.ClientID()).Return("", wantErr) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + ctrl.MockSendLine("GET_WOW") + assert.ErrorIs(t, <-handleErr, wantErr) + }) + t.Run("should handle challenge verification error", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + monitorResult := app.RecordRequestResult{ + ChallengeRequired: true, + ChallengeComplexity: 5 + rand.IntN(10), + } + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + monitorResult, nil, + ) + + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) + mockChallenges.EXPECT().GenerateNewChallenge(ctrl.Session.ClientID()).Return(faker.Word(), nil) + mockChallenges.EXPECT().VerifySolution( + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(false) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + ctrl.MockSendLineAndWaitResult("GET_WOW") + + result := ctrl.MockSendLineAndWaitResult("CHALLENGE_RESULT: " + faker.Word()) + assert.Equal(t, "ERR: CHALLENGE_VERIFICATION_FAILED", result) + assert.NoError(t, <-handleErr) + }) + t.Run("should handle session errors to write challenge", func(t *testing.T) { + ctx := context.Background() + deps := makeMockDeps(t) + h := NewHandler(deps) + + ctrl := services.NewMockSessionIOController() + + mockMonitor, _ := deps.RequestRateMonitor.(*app.MockRequestRateMonitor) + monitorResult := app.RecordRequestResult{ + ChallengeRequired: true, + ChallengeComplexity: 5 + rand.IntN(10), + } + mockMonitor.EXPECT().RecordRequest(ctx, ctrl.Session.ClientID()).Return( + monitorResult, nil, + ) + + mockChallenges, _ := deps.Challenges.(*app.MockChallenges) + mockChallenges.EXPECT().GenerateNewChallenge(ctrl.Session.ClientID()).Return(faker.Word(), nil) + + handleErr := make(chan error) + go func() { + handleErr <- h.Handle(ctx, ctrl.Session) + }() + + wantErr := errors.New(faker.Sentence()) + + ctrl.MockSetNextWriteError(wantErr) + ctrl.MockSendLineAndWaitResult("GET_WOW") + require.ErrorIs(t, <-handleErr, wantErr) + }) + }) +} diff --git a/internal/api/tcp/commands/register.go b/internal/api/tcp/commands/register.go new file mode 100644 index 0000000..7fb6bad --- /dev/null +++ b/internal/api/tcp/commands/register.go @@ -0,0 +1,18 @@ +package commands + +import ( + "word-of-wisdom-go/internal/app" + "word-of-wisdom-go/internal/di" + + "go.uber.org/dig" +) + +func Register(container *dig.Container) error { + return di.ProvideAll(container, + di.ProvideAs[*app.Challenges, challengesService], + di.ProvideAs[*app.RequestRateMonitor, requestRateMonitor], + di.ProvideAs[*app.WowQuery, wowQuery], + + NewHandler, + ) +} diff --git a/internal/api/tcp/server/mock_command_handler.go b/internal/api/tcp/server/mock_command_handler.go new file mode 100644 index 0000000..9ddbf45 --- /dev/null +++ b/internal/api/tcp/server/mock_command_handler.go @@ -0,0 +1,86 @@ +// Code generated by mockery. DO NOT EDIT. + +//go:build !release + +package server + +import ( + context "context" + services "word-of-wisdom-go/internal/services" + + mock "github.com/stretchr/testify/mock" +) + +// mockCommandHandler is an autogenerated mock type for the commandHandler type +type mockCommandHandler struct { + mock.Mock +} + +type mockCommandHandler_Expecter struct { + mock *mock.Mock +} + +func (_m *mockCommandHandler) EXPECT() *mockCommandHandler_Expecter { + return &mockCommandHandler_Expecter{mock: &_m.Mock} +} + +// Handle provides a mock function with given fields: ctx, session +func (_m *mockCommandHandler) Handle(ctx context.Context, session *services.SessionIO) error { + ret := _m.Called(ctx, session) + + if len(ret) == 0 { + panic("no return value specified for Handle") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *services.SessionIO) error); ok { + r0 = rf(ctx, session) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockCommandHandler_Handle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Handle' +type mockCommandHandler_Handle_Call struct { + *mock.Call +} + +// Handle is a helper method to define mock.On call +// - ctx context.Context +// - session *services.SessionIO +func (_e *mockCommandHandler_Expecter) Handle(ctx interface{}, session interface{}) *mockCommandHandler_Handle_Call { + return &mockCommandHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, session)} +} + +func (_c *mockCommandHandler_Handle_Call) Run(run func(ctx context.Context, session *services.SessionIO)) *mockCommandHandler_Handle_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*services.SessionIO)) + }) + return _c +} + +func (_c *mockCommandHandler_Handle_Call) Return(_a0 error) *mockCommandHandler_Handle_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockCommandHandler_Handle_Call) RunAndReturn(run func(context.Context, *services.SessionIO) error) *mockCommandHandler_Handle_Call { + _c.Call.Return(run) + return _c +} + +// newMockCommandHandler creates a new instance of mockCommandHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockCommandHandler(t interface { + mock.TestingT + Cleanup(func()) +}) *mockCommandHandler { + mock := &mockCommandHandler{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/api/tcp/server/register.go b/internal/api/tcp/server/register.go new file mode 100644 index 0000000..89c86b6 --- /dev/null +++ b/internal/api/tcp/server/register.go @@ -0,0 +1,16 @@ +package server + +import ( + "word-of-wisdom-go/internal/api/tcp/commands" + "word-of-wisdom-go/internal/di" + + "go.uber.org/dig" +) + +func Register(container *dig.Container) error { + return di.ProvideAll(container, + di.ProvideAs[*commands.CommandHandler, commandHandler], + + NewListener, + ) +} diff --git a/pkg/api/tcp/server/server.go b/internal/api/tcp/server/server.go similarity index 88% rename from pkg/api/tcp/server/server.go rename to internal/api/tcp/server/server.go index 68a1dc6..c43dc8e 100644 --- a/pkg/api/tcp/server/server.go +++ b/internal/api/tcp/server/server.go @@ -9,14 +9,16 @@ import ( "runtime/debug" "strings" "time" - "word-of-wisdom-go/pkg/api/tcp/commands" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services" - "word-of-wisdom-go/pkg/services/networking" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" "go.uber.org/dig" ) +type commandHandler interface { + Handle(ctx context.Context, session *services.SessionIO) error +} + type ListenerDeps struct { dig.In @@ -27,7 +29,7 @@ type ListenerDeps struct { MaxSessionDuration time.Duration `name:"config.tcpServer.maxSessionDuration"` // components - commands.CommandHandler + Handler commandHandler // services services.UUIDGenerator @@ -36,7 +38,7 @@ type ListenerDeps struct { type Listener struct { logger *slog.Logger listener net.Listener - commandHandler commands.CommandHandler + commandHandler commandHandler port int maxSessionDuration time.Duration listeningSignal chan struct{} @@ -47,7 +49,7 @@ func NewListener(deps ListenerDeps) *Listener { return &Listener{ port: deps.Port, maxSessionDuration: deps.MaxSessionDuration, - commandHandler: deps.CommandHandler, + commandHandler: deps.Handler, logger: deps.RootLogger.WithGroup("tcp.server"), listeningSignal: make(chan struct{}), uuidGenerator: deps.UUIDGenerator, @@ -94,7 +96,7 @@ func (l *Listener) processAcceptedConnection(ctx context.Context, c net.Conn) { l.logger.InfoContext(ctx, "Connection accepted", slog.String("remoteAddr", remoteAddr)) defer c.Close() - session := networking.NewSession(extractHost(remoteAddr), c) + session := services.NewSessionIO(extractHost(remoteAddr), c) if err := l.commandHandler.Handle(ctx, session); err != nil { l.logger.ErrorContext(ctx, "Failed processing command", @@ -109,7 +111,7 @@ func (l *Listener) Start(ctx context.Context) error { var err error l.listener, err = net.Listen("tcp", fmt.Sprintf(":%d", l.port)) if err != nil { - return err + return fmt.Errorf("failed to start listener: %w", err) } close(l.listeningSignal) diff --git a/pkg/api/tcp/server/server_test.go b/internal/api/tcp/server/server_test.go similarity index 85% rename from pkg/api/tcp/server/server_test.go rename to internal/api/tcp/server/server_test.go index 218c2ac..e98069f 100644 --- a/pkg/api/tcp/server/server_test.go +++ b/internal/api/tcp/server/server_test.go @@ -8,10 +8,8 @@ import ( "net" "testing" "time" - "word-of-wisdom-go/pkg/api/tcp/commands" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services" - "word-of-wisdom-go/pkg/services/networking" + "word-of-wisdom-go/internal/diag" + "word-of-wisdom-go/internal/services" "github.com/go-faker/faker/v4" "github.com/samber/lo" @@ -26,7 +24,7 @@ func TestListener(t *testing.T) { RootLogger: diag.RootTestLogger(), Port: 50000 + rand.IntN(15000), MaxSessionDuration: 10 * time.Second, - CommandHandler: commands.NewMockCommandHandler(t), + Handler: newMockCommandHandler(t), UUIDGenerator: services.NewUUIDGenerator(), } } @@ -56,11 +54,11 @@ func TestListener(t *testing.T) { srv.WaitListening() defer srv.Close() - mockHandler, _ := deps.CommandHandler.(*commands.MockCommandHandler) + mockHandler, _ := deps.Handler.(*mockCommandHandler) handleSignal := make(chan struct{}) wantData := faker.Sentence() mockHandler.EXPECT().Handle(mock.Anything, mock.Anything).RunAndReturn( - func(_ context.Context, s networking.Session) error { + func(_ context.Context, s *services.SessionIO) error { gotData, err := s.ReadLine() require.NoError(t, err) assert.Equal(t, wantData, gotData) @@ -86,10 +84,10 @@ func TestListener(t *testing.T) { srv.WaitListening() defer srv.Close() - mockHandler, _ := deps.CommandHandler.(*commands.MockCommandHandler) + mockHandler, _ := deps.Handler.(*mockCommandHandler) handleSignal := make(chan struct{}) mockHandler.EXPECT().Handle(mock.Anything, mock.Anything).RunAndReturn( - func(_ context.Context, _ networking.Session) error { + func(_ context.Context, _ *services.SessionIO) error { close(handleSignal) return errors.New(faker.Sentence()) }, @@ -112,10 +110,10 @@ func TestListener(t *testing.T) { srv.WaitListening() defer srv.Close() - mockHandler, _ := deps.CommandHandler.(*commands.MockCommandHandler) + mockHandler, _ := deps.Handler.(*mockCommandHandler) handleSignal := make(chan struct{}) mockHandler.EXPECT().Handle(mock.Anything, mock.Anything).RunAndReturn( - func(_ context.Context, _ networking.Session) error { + func(_ context.Context, _ *services.SessionIO) error { close(handleSignal) panic(errors.New(faker.Sentence())) }, diff --git a/pkg/app/challenges/challenges.go b/internal/app/challenges.go similarity index 68% rename from pkg/app/challenges/challenges.go rename to internal/app/challenges.go index a698afb..dc384d5 100644 --- a/pkg/app/challenges/challenges.go +++ b/internal/app/challenges.go @@ -1,4 +1,4 @@ -package challenges +package app import ( "context" @@ -8,7 +8,7 @@ import ( "math/big" "strconv" "time" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/services" "go.uber.org/dig" ) @@ -33,26 +33,6 @@ func countLeadingZeros(hash []byte) int { return count } -type Challenges interface { - GenerateNewChallenge(clientID string) (string, error) - VerifySolution( - complexity int, - challenge string, - solution string, - ) bool - - // SolveChallenge returns a nonce that is a solution of the challenge. - // It is used by client side only and - // in real world scenario this may sit in it's own repo - // but keeping it simple for now. - // Returns error if the solution was not found - SolveChallenge( - ctx context.Context, - complexity int, - challenge string, - ) (string, error) -} - type Deps struct { dig.In `ignore-unexported:"true"` @@ -66,21 +46,21 @@ type Deps struct { computeHashFn func(input []byte) []byte } -type challenges struct { - Deps +type Challenges struct { + deps Deps } -func (c *challenges) generateRandomBytes(size int) ([]byte, error) { +func (c *Challenges) generateRandomBytes(size int) ([]byte, error) { nonce := make([]byte, size) - _, err := c.CryptoRandReader(nonce) + _, err := c.deps.CryptoRandReader(nonce) if err != nil { return nil, err } return nonce, nil } -func (c *challenges) GenerateNewChallenge(clientID string) (string, error) { - nowBytes := big.NewInt(c.Now().UnixNano()).Bytes() +func (c *Challenges) GenerateNewChallenge(clientID string) (string, error) { + nowBytes := big.NewInt(c.deps.Now().UnixNano()).Bytes() nonce, err := c.generateRandomBytes(16) // TODO: may need to make it smaller (or configurable) if err != nil { return "", err @@ -92,7 +72,7 @@ func (c *challenges) GenerateNewChallenge(clientID string) (string, error) { return hex.EncodeToString(challengeBytes), nil } -func (c *challenges) VerifySolution( +func (c *Challenges) VerifySolution( complexity int, challenge string, solution string, @@ -101,12 +81,17 @@ func (c *challenges) VerifySolution( copy(hashInputBytes, []byte(challenge)) hashInputBytes[len(challenge)] = ':' copy(hashInputBytes[len(challenge)+1:], []byte(solution)) - actualHash := c.computeHashFn(hashInputBytes) + actualHash := c.deps.computeHashFn(hashInputBytes) leadingZerosNum := countLeadingZeros(actualHash) return leadingZerosNum >= complexity } -func (c *challenges) SolveChallenge(ctx context.Context, complexity int, challenge string) (string, error) { +// SolveChallenge returns a nonce that is a solution of the challenge. +// It is used by client side only and +// in real world scenario this may sit in it's own repo +// but keeping it simple for now. +// Returns error if the solution was not found. +func (c *Challenges) SolveChallenge(ctx context.Context, complexity int, challenge string) (string, error) { challengePartEnd := len(challenge) hashInput := make([]byte, challengePartEnd+20) // we reserve 20 bytes for solution which should be enough copy(hashInput, []byte(challenge)) @@ -115,7 +100,7 @@ func (c *challenges) SolveChallenge(ctx context.Context, complexity int, challen deadline, hasDeadline := ctx.Deadline() if !hasDeadline { - deadline = c.Deps.Now().Add(c.MaxSolveChallengeDuration) + deadline = c.deps.Now().Add(c.deps.MaxSolveChallengeDuration) } /* @@ -131,13 +116,13 @@ func (c *challenges) SolveChallenge(ctx context.Context, complexity int, challen for { nonceStr := strconv.Itoa(nonce) copy(hashInput[challengePartEnd+1:], []byte(nonceStr)) - hash := c.computeHashFn(hashInput[:challengePartEnd+1+len(nonceStr)]) + hash := c.deps.computeHashFn(hashInput[:challengePartEnd+1+len(nonceStr)]) leadingZeros := countLeadingZeros(hash) if leadingZeros >= complexity { return nonceStr, nil } - if c.Deps.Now().UnixNano() >= deadline.UnixNano() { + if c.deps.Now().UnixNano() >= deadline.UnixNano() { break } @@ -146,11 +131,11 @@ func (c *challenges) SolveChallenge(ctx context.Context, complexity int, challen return "", errors.New("failed to solve challenge: deadline reached") } -func NewChallenges(deps Deps) Challenges { +func NewChallenges(deps Deps) *Challenges { if deps.computeHashFn == nil { deps.computeHashFn = computeHash } - return &challenges{ - Deps: deps, + return &Challenges{ + deps: deps, } } diff --git a/pkg/app/challenges/challenges_bench_test.go b/internal/app/challenges_bench_test.go similarity index 95% rename from pkg/app/challenges/challenges_bench_test.go rename to internal/app/challenges_bench_test.go index ab9b266..a00730b 100644 --- a/pkg/app/challenges/challenges_bench_test.go +++ b/internal/app/challenges_bench_test.go @@ -1,11 +1,11 @@ -package challenges +package app import ( "context" cryptoRand "crypto/rand" "testing" "time" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/services" "github.com/go-faker/faker/v4" "github.com/stretchr/testify/require" diff --git a/pkg/app/challenges/challenges_test.go b/internal/app/challenges_test.go similarity index 99% rename from pkg/app/challenges/challenges_test.go rename to internal/app/challenges_test.go index 51b6331..bc85f9d 100644 --- a/pkg/app/challenges/challenges_test.go +++ b/internal/app/challenges_test.go @@ -1,4 +1,4 @@ -package challenges +package app import ( "context" @@ -10,7 +10,7 @@ import ( "strconv" "testing" "time" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/services" "github.com/go-faker/faker/v4" "github.com/stretchr/testify/assert" diff --git a/pkg/app/challenges/mock_challenges.go b/internal/app/mock_challenges.go similarity index 98% rename from pkg/app/challenges/mock_challenges.go rename to internal/app/mock_challenges.go index b34856c..366035b 100644 --- a/pkg/app/challenges/mock_challenges.go +++ b/internal/app/mock_challenges.go @@ -2,7 +2,7 @@ //go:build !release -package challenges +package app import ( context "context" @@ -10,7 +10,7 @@ import ( mock "github.com/stretchr/testify/mock" ) -// MockChallenges is an autogenerated mock type for the Challenges type +// MockChallenges is an autogenerated mock type for the mockChallenges type type MockChallenges struct { mock.Mock } diff --git a/pkg/app/challenges/mock_request_rate_monitor.go b/internal/app/mock_request_rate_monitor.go similarity index 98% rename from pkg/app/challenges/mock_request_rate_monitor.go rename to internal/app/mock_request_rate_monitor.go index 46618cb..c34d761 100644 --- a/pkg/app/challenges/mock_request_rate_monitor.go +++ b/internal/app/mock_request_rate_monitor.go @@ -2,7 +2,7 @@ //go:build !release -package challenges +package app import ( context "context" @@ -10,7 +10,7 @@ import ( mock "github.com/stretchr/testify/mock" ) -// MockRequestRateMonitor is an autogenerated mock type for the RequestRateMonitor type +// MockRequestRateMonitor is an autogenerated mock type for the mockRequestRateMonitor type type MockRequestRateMonitor struct { mock.Mock } diff --git a/internal/app/mock_wow_query.go b/internal/app/mock_wow_query.go new file mode 100644 index 0000000..68364eb --- /dev/null +++ b/internal/app/mock_wow_query.go @@ -0,0 +1,94 @@ +// Code generated by mockery. DO NOT EDIT. + +//go:build !release + +package app + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockWowQuery is an autogenerated mock type for the mockWowQuery type +type MockWowQuery struct { + mock.Mock +} + +type MockWowQuery_Expecter struct { + mock *mock.Mock +} + +func (_m *MockWowQuery) EXPECT() *MockWowQuery_Expecter { + return &MockWowQuery_Expecter{mock: &_m.Mock} +} + +// GetNextWoW provides a mock function with given fields: _a0 +func (_m *MockWowQuery) GetNextWoW(_a0 context.Context) (string, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for GetNextWoW") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWowQuery_GetNextWoW_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNextWoW' +type MockWowQuery_GetNextWoW_Call struct { + *mock.Call +} + +// GetNextWoW is a helper method to define mock.On call +// - _a0 context.Context +func (_e *MockWowQuery_Expecter) GetNextWoW(_a0 interface{}) *MockWowQuery_GetNextWoW_Call { + return &MockWowQuery_GetNextWoW_Call{Call: _e.mock.On("GetNextWoW", _a0)} +} + +func (_c *MockWowQuery_GetNextWoW_Call) Run(run func(_a0 context.Context)) *MockWowQuery_GetNextWoW_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockWowQuery_GetNextWoW_Call) Return(_a0 string, _a1 error) *MockWowQuery_GetNextWoW_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWowQuery_GetNextWoW_Call) RunAndReturn(run func(context.Context) (string, error)) *MockWowQuery_GetNextWoW_Call { + _c.Call.Return(run) + return _c +} + +// NewMockWowQuery creates a new instance of MockWowQuery. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockWowQuery(t interface { + mock.TestingT + Cleanup(func()) +}) *MockWowQuery { + mock := &MockWowQuery{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/app/mocks.go b/internal/app/mocks.go new file mode 100644 index 0000000..08fde50 --- /dev/null +++ b/internal/app/mocks.go @@ -0,0 +1,29 @@ +//go:build !release + +package app + +import "context" + +// Mock interfaces are used to generate mock implementations of all of the components +// that will be reused elsewhere in a system. This helps to minimize the amount of +// duplicate mock implementations that need to be written. + +type mockRequestRateMonitor interface { + RecordRequest(ctx context.Context, clientID string) (RecordRequestResult, error) +} + +var _ mockRequestRateMonitor = (*RequestRateMonitor)(nil) + +type mockChallenges interface { + GenerateNewChallenge(clientID string) (string, error) + VerifySolution(complexity int, challenge, solution string) bool + SolveChallenge(ctx context.Context, complexity int, challenge string) (string, error) +} + +var _ mockChallenges = (*Challenges)(nil) + +type mockWowQuery interface { + GetNextWoW(_ context.Context) (string, error) +} + +var _ mockWowQuery = (*WowQuery)(nil) diff --git a/pkg/app/challenges/monitor.go b/internal/app/monitor.go similarity index 73% rename from pkg/app/challenges/monitor.go rename to internal/app/monitor.go index 8fd2079..cb998dd 100644 --- a/pkg/app/challenges/monitor.go +++ b/internal/app/monitor.go @@ -1,11 +1,11 @@ -package challenges +package app import ( "context" "sync" "sync/atomic" "time" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/services" "github.com/samber/lo" "go.uber.org/dig" @@ -16,10 +16,6 @@ type RecordRequestResult struct { ChallengeComplexity int } -type RequestRateMonitor interface { - RecordRequest(ctx context.Context, clientID string) (RecordRequestResult, error) -} - type ChallengeConditionFunc func(nextClientCount, nextGlobalCount int64) RecordRequestResult type RequestRateMonitorDeps struct { @@ -43,8 +39,8 @@ type RequestRateMonitorDeps struct { challengeConditionFunc ChallengeConditionFunc } -type requestRateMonitor struct { - RequestRateMonitorDeps +type RequestRateMonitor struct { + deps RequestRateMonitorDeps requestRateByClient sync.Map globalRequestsCount atomic.Int64 @@ -54,21 +50,21 @@ type requestRateMonitor struct { } // challengeCondition defines if challenge will be required and the complexity. -func (m *requestRateMonitor) challengeCondition(nextClientCounter, nextGlobalCount int64) RecordRequestResult { +func (m *RequestRateMonitor) challengeCondition(nextClientCounter, nextGlobalCount int64) RecordRequestResult { challengeRequired := false complexityRequired := 0 - if nextClientCounter > m.MaxUnverifiedClientRequests { + if nextClientCounter > m.deps.MaxUnverifiedClientRequests { challengeRequired = true // We just grow it linearly, at some point (somewhere after 5 or 6) // it's just going to become unreasonably complex to proceed - complexityRequired = int(nextClientCounter / m.MaxUnverifiedClientRequests) + complexityRequired = int(nextClientCounter / m.deps.MaxUnverifiedClientRequests) } - if !challengeRequired && nextGlobalCount > m.MaxUnverifiedRequests { + if !challengeRequired && nextGlobalCount > m.deps.MaxUnverifiedRequests { challengeRequired = true - globalCapacityScale := nextGlobalCount / m.MaxUnverifiedRequests + globalCapacityScale := nextGlobalCount / m.deps.MaxUnverifiedRequests // If we're under pressure globally (current rate is 2x more than global threshold) // then increase min complexity for all users @@ -84,7 +80,7 @@ func (m *requestRateMonitor) challengeCondition(nextClientCounter, nextGlobalCou } } -func (m *requestRateMonitor) RecordRequest(_ context.Context, clientID string) (RecordRequestResult, error) { +func (m *RequestRateMonitor) RecordRequest(_ context.Context, clientID string) (RecordRequestResult, error) { // We are not using the context yet, but in a real world system it may be required // since we will very likely store counters somewhere @@ -93,9 +89,9 @@ func (m *requestRateMonitor) RecordRequest(_ context.Context, clientID string) ( // and keep this data in something like redis, or use some other replication mechanism // and also use some sliding window algo with per client window. - now := m.Now().UnixMilli() + now := m.deps.Now().UnixMilli() lastTimestamp := m.windowStartedAt.Load() - if now-lastTimestamp > m.WindowDuration.Milliseconds() { + if now-lastTimestamp > m.deps.WindowDuration.Milliseconds() { if m.windowStartedAt.CompareAndSwap(lastTimestamp, now) { m.globalRequestsCount.Store(0) m.requestRateByClient.Clear() @@ -106,15 +102,15 @@ func (m *requestRateMonitor) RecordRequest(_ context.Context, clientID string) ( nextClientCounter := atomic.AddInt64(currentClientCounter.(*int64), 1) nextGlobalCount := m.globalRequestsCount.Add(1) - return m.challengeConditionFunc(nextClientCounter, nextGlobalCount), nil + return m.deps.challengeConditionFunc(nextClientCounter, nextGlobalCount), nil } -func NewRequestRateMonitor(deps RequestRateMonitorDeps) RequestRateMonitor { - m := &requestRateMonitor{ - RequestRateMonitorDeps: deps, +func NewRequestRateMonitor(deps RequestRateMonitorDeps) *RequestRateMonitor { + m := &RequestRateMonitor{ + deps: deps, } - if m.challengeConditionFunc == nil { - m.challengeConditionFunc = m.challengeCondition + if m.deps.challengeConditionFunc == nil { + m.deps.challengeConditionFunc = m.challengeCondition } return m } diff --git a/pkg/app/challenges/monitor_test.go b/internal/app/monitor_test.go similarity index 93% rename from pkg/app/challenges/monitor_test.go rename to internal/app/monitor_test.go index c368d36..782975d 100644 --- a/pkg/app/challenges/monitor_test.go +++ b/internal/app/monitor_test.go @@ -1,11 +1,11 @@ -package challenges +package app import ( "context" "math/rand/v2" "testing" "time" - "word-of-wisdom-go/pkg/services" + "word-of-wisdom-go/internal/services" "github.com/go-faker/faker/v4" "github.com/stretchr/testify/assert" @@ -26,7 +26,7 @@ func TestRequestRateMonitor(t *testing.T) { t.Run("challengeCondition", func(t *testing.T) { t.Run("should allow unverified requests within the limit", func(t *testing.T) { deps := newMockDeps() - monitor, _ := NewRequestRateMonitor(deps).(*requestRateMonitor) + monitor := NewRequestRateMonitor(deps) assert.Equal( t, RecordRequestResult{}, @@ -35,7 +35,7 @@ func TestRequestRateMonitor(t *testing.T) { }) t.Run("should require client requests verification above threshold", func(t *testing.T) { deps := newMockDeps() - monitor, _ := NewRequestRateMonitor(deps).(*requestRateMonitor) + monitor := NewRequestRateMonitor(deps) assert.Equal( t, RecordRequestResult{ @@ -47,7 +47,7 @@ func TestRequestRateMonitor(t *testing.T) { }) t.Run("should grow client request verification complexity linearly", func(t *testing.T) { deps := newMockDeps() - monitor, _ := NewRequestRateMonitor(deps).(*requestRateMonitor) + monitor := NewRequestRateMonitor(deps) wantComplexity := 1 + rand.IntN(10) assert.Equal( t, @@ -60,7 +60,7 @@ func TestRequestRateMonitor(t *testing.T) { }) t.Run("should require global requests verification above threshold", func(t *testing.T) { deps := newMockDeps() - monitor, _ := NewRequestRateMonitor(deps).(*requestRateMonitor) + monitor := NewRequestRateMonitor(deps) assert.Equal( t, RecordRequestResult{ @@ -72,7 +72,7 @@ func TestRequestRateMonitor(t *testing.T) { }) t.Run("should increase global requests verification if at 2x global capacity", func(t *testing.T) { deps := newMockDeps() - monitor, _ := NewRequestRateMonitor(deps).(*requestRateMonitor) + monitor := NewRequestRateMonitor(deps) assert.Equal( t, RecordRequestResult{ diff --git a/internal/app/register.go b/internal/app/register.go new file mode 100644 index 0000000..92d74ce --- /dev/null +++ b/internal/app/register.go @@ -0,0 +1,15 @@ +package app + +import ( + "word-of-wisdom-go/internal/di" + + "go.uber.org/dig" +) + +func Register(container *dig.Container) error { + return di.ProvideAll(container, + NewChallenges, + NewRequestRateMonitor, + NewWowQuery, + ) +} diff --git a/pkg/app/wow/query.go b/internal/app/wow.go similarity index 93% rename from pkg/app/wow/query.go rename to internal/app/wow.go index 95b911f..1fd4537 100644 --- a/pkg/app/wow/query.go +++ b/internal/app/wow.go @@ -1,20 +1,16 @@ //nolint:lll //hardcoded wow phrases -package wow +package app import ( "context" "math/rand/v2" ) -type Query interface { - GetNextWoW(ctx context.Context) (string, error) -} - -type query struct { +type WowQuery struct { phrases []string } -func (q *query) GetNextWoW(_ context.Context) (string, error) { +func (q *WowQuery) GetNextWoW(_ context.Context) (string, error) { // Using hardcoded list of phrases // We may want to change it to go to some API to get a next phrase // and fallback to the below if it failed. Or store them in a DB... @@ -23,8 +19,8 @@ func (q *query) GetNextWoW(_ context.Context) (string, error) { return q.phrases[nextIndex], nil } -func NewQuery() Query { - return &query{ +func NewWowQuery() *WowQuery { + return &WowQuery{ phrases: []string{ "You create your own opportunities. Success doesn’t just come and find you–you have to go out and get it.", "Never break your promises. Keep every promise; it makes you credible.", diff --git a/pkg/app/wow/query_test.go b/internal/app/wow_test.go similarity index 92% rename from pkg/app/wow/query_test.go rename to internal/app/wow_test.go index 2e759f8..fe8664e 100644 --- a/pkg/app/wow/query_test.go +++ b/internal/app/wow_test.go @@ -1,4 +1,4 @@ -package wow +package app import ( "context" @@ -11,7 +11,7 @@ import ( func TestQuery(t *testing.T) { t.Run("GetNextWoW", func(t *testing.T) { t.Run("should get next random phrase", func(t *testing.T) { - query := NewQuery() + query := NewWowQuery() phrase1, err := query.GetNextWoW(context.Background()) require.NoError(t, err) diff --git a/config/default.json b/internal/config/default.json similarity index 100% rename from config/default.json rename to internal/config/default.json diff --git a/config/load.go b/internal/config/load.go similarity index 81% rename from config/load.go rename to internal/config/load.go index 4d0e4c1..615b5cb 100644 --- a/config/load.go +++ b/internal/config/load.go @@ -24,7 +24,8 @@ func mergeResourceCfg(cfg *viper.Viper, resourceName string) error { } type LoadOpts struct { - env string + env string + defaultConfigFileName string } func (opts *LoadOpts) WithEnv(val string) *LoadOpts { @@ -36,7 +37,8 @@ func (opts *LoadOpts) WithEnv(val string) *LoadOpts { func NewLoadOpts() *LoadOpts { return &LoadOpts{ - env: "local", + env: "local", + defaultConfigFileName: "default.json", } } @@ -44,7 +46,7 @@ func Load(opts *LoadOpts) (*viper.Viper, error) { cfg := viper.New() cfg.SetConfigType("json") - if err := mergeResourceCfg(cfg, "default.json"); err != nil { + if err := mergeResourceCfg(cfg, opts.defaultConfigFileName); err != nil { return nil, err } diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..69bc0bf --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoad(t *testing.T) { + t.Run("should load local config with default opts", func(t *testing.T) { + cfg, err := Load(NewLoadOpts()) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.Equal(t, "DEBUG", cfg.GetString("defaultLogLevel")) + }) + t.Run("should fail if no default config is found", func(t *testing.T) { + opts := NewLoadOpts() + opts.defaultConfigFileName = "not-existing.json" + cfg, err := Load(opts) + require.ErrorIs(t, err, os.ErrNotExist) + require.Nil(t, cfg) + }) + t.Run("should load env specific config", func(t *testing.T) { + cfg, err := Load(NewLoadOpts().WithEnv("test")) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.Equal(t, "DEBUG", cfg.GetString("defaultLogLevel")) + }) + t.Run("should return error if config is not found", func(t *testing.T) { + cfg, err := Load(NewLoadOpts().WithEnv("not-existing")) + require.ErrorIs(t, err, os.ErrNotExist) + require.Nil(t, cfg) + }) +} diff --git a/internal/config/local.json b/internal/config/local.json new file mode 100644 index 0000000..969a07b --- /dev/null +++ b/internal/config/local.json @@ -0,0 +1,3 @@ +{ + "defaultLogLevel": "DEBUG" +} \ No newline at end of file diff --git a/config/provide.go b/internal/config/provide.go similarity index 98% rename from config/provide.go rename to internal/config/provide.go index b795cd8..15e3592 100644 --- a/config/provide.go +++ b/internal/config/provide.go @@ -2,7 +2,7 @@ package config import ( "fmt" - "word-of-wisdom-go/pkg/di" + "word-of-wisdom-go/internal/di" "github.com/spf13/viper" "go.uber.org/dig" @@ -29,7 +29,6 @@ func (p configValueProvider) asInt64() di.ConstructorWithOpts { return di.ProvideValue(p.cfg.GetInt64(p.configPath), dig.Name(p.diPath)) } -/* func (p configValueProvider) asString() di.ConstructorWithOpts { return di.ProvideValue(p.cfg.GetString(p.configPath), dig.Name(p.diPath)) } @@ -37,7 +36,6 @@ func (p configValueProvider) asString() di.ConstructorWithOpts { func (p configValueProvider) asBool() di.ConstructorWithOpts { return di.ProvideValue(p.cfg.GetBool(p.configPath), dig.Name(p.diPath)) } -*/ func (p configValueProvider) asDuration() di.ConstructorWithOpts { return di.ProvideValue(p.cfg.GetDuration(p.configPath), dig.Name(p.diPath)) diff --git a/internal/config/provide_test.go b/internal/config/provide_test.go new file mode 100644 index 0000000..c610b98 --- /dev/null +++ b/internal/config/provide_test.go @@ -0,0 +1,108 @@ +package config + +import ( + "math/rand/v2" + "testing" + "time" + "word-of-wisdom-go/internal/di" + + "github.com/go-faker/faker/v4" + "github.com/samber/lo" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/dig" +) + +func Test_provideConfigValue(t *testing.T) { + t.Run("should provide config value as int", func(t *testing.T) { + cfg := viper.New() + configKey := "int-cfg-key" + cfg.Set(configKey, rand.IntN(1000)) + + type configReceiver struct { + dig.In + Value int `name:"config.int-cfg-key"` + } + + container := dig.New() + require.NoError(t, di.ProvideAll(container, provideConfigValue(cfg, configKey).asInt())) + + require.NoError(t, container.Invoke(func(receiver configReceiver) { + require.Equal(t, cfg.GetInt(configKey), receiver.Value) + })) + }) + + t.Run("should provide config value as int64", func(t *testing.T) { + cfg := viper.New() + configKey := "int-cfg-key" + cfg.Set(configKey, rand.Int64N(1000)) + + type configReceiver struct { + dig.In + Value int64 `name:"config.int-cfg-key"` + } + + container := dig.New() + require.NoError(t, di.ProvideAll(container, provideConfigValue(cfg, configKey).asInt64())) + + require.NoError(t, container.Invoke(func(receiver configReceiver) { + require.Equal(t, cfg.GetInt64(configKey), receiver.Value) + })) + }) + + t.Run("should provide config value as string", func(t *testing.T) { + cfg := viper.New() + configKey := "string-cfg" + cfg.Set(configKey, faker.Sentence()) + + type configReceiver struct { + dig.In + Value string `name:"config.string-cfg"` + } + container := dig.New() + require.NoError(t, di.ProvideAll(container, provideConfigValue(cfg, configKey).asString())) + require.NoError(t, container.Invoke(func(receiver configReceiver) { + require.Equal(t, cfg.GetString(configKey), receiver.Value) + })) + }) + + t.Run("should provide config value as bool", func(t *testing.T) { + cfg := viper.New() + configKey := "bool-cfg" + cfg.Set(configKey, lo.If(rand.IntN(2) == 1, true).Else(false)) + type configReceiver struct { + dig.In + Value bool `name:"config.bool-cfg"` + } + container := dig.New() + require.NoError(t, di.ProvideAll(container, provideConfigValue(cfg, configKey).asBool())) + require.NoError(t, container.Invoke(func(receiver configReceiver) { + require.Equal(t, cfg.GetBool(configKey), receiver.Value) + })) + }) + + t.Run("should provide config value as duration", func(t *testing.T) { + cfg := viper.New() + configKey := "duration-cfg" + cfg.Set(configKey, rand.IntN(1000)) + type configReceiver struct { + dig.In + Value time.Duration `name:"config.duration-cfg"` + } + container := dig.New() + require.NoError(t, di.ProvideAll(container, provideConfigValue(cfg, configKey).asDuration())) + require.NoError(t, container.Invoke(func(receiver configReceiver) { + require.Equal(t, cfg.GetDuration(configKey), receiver.Value) + })) + }) + + t.Run("should panic if config key is not found", func(t *testing.T) { + cfg := viper.New() + configKey := "int-cfg-key" + container := dig.New() + assert.Panics(t, func() { + require.NoError(t, di.ProvideAll(container, provideConfigValue(cfg, configKey).asInt())) + }) + }) +} diff --git a/internal/config/test.json b/internal/config/test.json new file mode 100644 index 0000000..969a07b --- /dev/null +++ b/internal/config/test.json @@ -0,0 +1,3 @@ +{ + "defaultLogLevel": "DEBUG" +} \ No newline at end of file diff --git a/pkg/di/dig.go b/internal/di/dig.go similarity index 83% rename from pkg/di/dig.go rename to internal/di/dig.go index 562eea4..96b45c7 100644 --- a/pkg/di/dig.go +++ b/internal/di/dig.go @@ -67,3 +67,13 @@ func ProvideWithArgErr[ return constructor(arg, cArg) } } + +// ProvideAs is used to provide one type as another, typically +// used to provide implementation struct as particular interface. +func ProvideAs[TSource any, TTarget any](source TSource) (TTarget, error) { + target, ok := any(source).(TTarget) + if !ok { + return target, fmt.Errorf("failed to cast %T to %T", source, target) + } + return target, nil +} diff --git a/pkg/di/dig_test.go b/internal/di/dig_test.go similarity index 100% rename from pkg/di/dig_test.go rename to internal/di/dig_test.go diff --git a/pkg/diag/attributes.go b/internal/diag/attributes.go similarity index 100% rename from pkg/diag/attributes.go rename to internal/diag/attributes.go diff --git a/pkg/diag/mock_slog_handler.go b/internal/diag/mock_slog_handler.go similarity index 100% rename from pkg/diag/mock_slog_handler.go rename to internal/diag/mock_slog_handler.go diff --git a/pkg/diag/slog.go b/internal/diag/slog.go similarity index 100% rename from pkg/diag/slog.go rename to internal/diag/slog.go diff --git a/pkg/diag/slog_test.go b/internal/diag/slog_test.go similarity index 100% rename from pkg/diag/slog_test.go rename to internal/diag/slog_test.go diff --git a/pkg/diag/testing.go b/internal/diag/testing.go similarity index 100% rename from pkg/diag/testing.go rename to internal/diag/testing.go diff --git a/pkg/services/register.go b/internal/services/register.go similarity index 91% rename from pkg/services/register.go rename to internal/services/register.go index 2d98cb9..6ea4dea 100644 --- a/pkg/services/register.go +++ b/internal/services/register.go @@ -3,7 +3,7 @@ package services import ( "crypto/rand" "time" - "word-of-wisdom-go/pkg/di" + "word-of-wisdom-go/internal/di" "go.uber.org/dig" ) diff --git a/internal/services/session_io.go b/internal/services/session_io.go new file mode 100644 index 0000000..eb8c0d8 --- /dev/null +++ b/internal/services/session_io.go @@ -0,0 +1,37 @@ +package services + +import ( + "bufio" + "io" +) + +type SessionIO struct { + clientID string + stream io.ReadWriter + reader *bufio.Reader +} + +func (s *SessionIO) ClientID() string { + return s.clientID +} + +func (s *SessionIO) ReadLine() (string, error) { + line, _, err := s.reader.ReadLine() + if err != nil { + return "", err + } + return string(line), nil +} + +func (s *SessionIO) WriteLine(data string) error { + _, err := s.stream.Write(append([]byte(data), '\n')) + return err +} + +func NewSessionIO(clientID string, stream io.ReadWriter) *SessionIO { + return &SessionIO{ + clientID: clientID, + stream: stream, + reader: bufio.NewReader(stream), + } +} diff --git a/pkg/services/networking/session_test.go b/internal/services/session_io_test.go similarity index 82% rename from pkg/services/networking/session_test.go rename to internal/services/session_io_test.go index 7d951a1..38388eb 100644 --- a/pkg/services/networking/session_test.go +++ b/internal/services/session_io_test.go @@ -1,4 +1,4 @@ -package networking +package services import ( "bytes" @@ -17,13 +17,13 @@ func TestSession(t *testing.T) { lo.Must(buffer.WriteString(wantLine)) lo.Must(buffer.WriteRune('\n')) - session := NewSession(faker.UUIDHyphenated(), &buffer) + session := NewSessionIO(faker.UUIDHyphenated(), &buffer) assert.Equal(t, wantLine, lo.Must(session.ReadLine())) assert.NotEmpty(t, session.ClientID()) }) t.Run("should handle read errors", func(t *testing.T) { var buffer bytes.Buffer - session := NewSession(faker.UUIDHyphenated(), &buffer) + session := NewSessionIO(faker.UUIDHyphenated(), &buffer) _, err := session.ReadLine() assert.Error(t, err) }) @@ -33,7 +33,7 @@ func TestSession(t *testing.T) { var buffer bytes.Buffer wantLine := faker.Sentence() - session := NewSession(faker.UUIDHyphenated(), &buffer) + session := NewSessionIO(faker.UUIDHyphenated(), &buffer) lo.Must0(session.WriteLine(wantLine)) assert.Equal(t, wantLine+"\n", buffer.String()) }) diff --git a/internal/services/testing.go b/internal/services/testing.go new file mode 100644 index 0000000..7c3ad4c --- /dev/null +++ b/internal/services/testing.go @@ -0,0 +1,111 @@ +//go:build !release + +package services + +import ( + "strings" + "time" + + "github.com/go-faker/faker/v4" +) + +type MockNow struct { + value time.Time +} + +var _ TimeProvider = &MockNow{} + +func (m *MockNow) SetValue(t time.Time) { + m.value = t +} + +func (m *MockNow) Increment(duration time.Duration) { + m.value = m.value.Add(duration) +} + +func (m *MockNow) Now() time.Time { + return m.value +} + +func NewMockNow() *MockNow { + return &MockNow{ + value: time.UnixMilli(faker.RandomUnixTime()), + } +} + +func MockNowValue(p TimeProvider) time.Time { + mp, ok := p.(*MockNow) + if !ok { + panic("provided TimeProvider is not a MockNow") + } + return mp.value +} + +type mockSessionIOStream struct { + readBuffer chan string + writeBuffer chan string + nextReadError error + nextWriteError error +} + +func (m *mockSessionIOStream) Read(p []byte) (int, error) { + line := <-m.readBuffer + if m.nextReadError != nil { + return 0, m.nextReadError + } + copy(p, line) + return len(line), nil +} + +func (m *mockSessionIOStream) Write(p []byte) (int, error) { + line := string(p) + go func() { + m.writeBuffer <- line + }() + if m.nextWriteError != nil { + return 0, m.nextWriteError + } + return len(p), nil +} + +type MockSessionIOController struct { + Session *SessionIO + stream *mockSessionIOStream +} + +func (m *MockSessionIOController) MockSendLine(line string) { + go func() { + m.stream.readBuffer <- line + "\n" + }() +} + +func (m *MockSessionIOController) MockSendLineAndWaitResult(line string) string { + go func() { + m.stream.readBuffer <- line + "\n" + }() + return m.MockWaitResult() +} + +func (m *MockSessionIOController) MockWaitResult() string { + result := <-m.stream.writeBuffer + return strings.TrimSuffix(result, "\n") +} + +func (m *MockSessionIOController) MockSetNextReadError(err error) { + m.stream.nextReadError = err +} + +func (m *MockSessionIOController) MockSetNextWriteError(err error) { + m.stream.nextWriteError = err +} + +func NewMockSessionIOController() *MockSessionIOController { + stream := &mockSessionIOStream{ + readBuffer: make(chan string), + writeBuffer: make(chan string), + } + return &MockSessionIOController{ + Session: NewSessionIO(faker.UUIDHyphenated(), stream), + stream: stream, + } +} diff --git a/pkg/services/time.go b/internal/services/time.go similarity index 100% rename from pkg/services/time.go rename to internal/services/time.go diff --git a/pkg/services/time_test.go b/internal/services/time_test.go similarity index 100% rename from pkg/services/time_test.go rename to internal/services/time_test.go diff --git a/pkg/services/uuid.go b/internal/services/uuid.go similarity index 100% rename from pkg/services/uuid.go rename to internal/services/uuid.go diff --git a/pkg/tools/tools.go b/internal/tools.go similarity index 79% rename from pkg/tools/tools.go rename to internal/tools.go index dc80ee8..4d0f887 100644 --- a/pkg/tools/tools.go +++ b/internal/tools.go @@ -1,6 +1,6 @@ //go:build tools -package tools +package services import ( _ "github.com/vektra/mockery/v2" diff --git a/pkg/api/tcp/commands/handler.go b/pkg/api/tcp/commands/handler.go deleted file mode 100644 index 1109573..0000000 --- a/pkg/api/tcp/commands/handler.go +++ /dev/null @@ -1,128 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "log/slog" - "strings" - "word-of-wisdom-go/pkg/app/challenges" - "word-of-wisdom-go/pkg/app/wow" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services/networking" - - "go.uber.org/dig" -) - -type CommandHandlerDeps struct { - dig.In - - RootLogger *slog.Logger - - // components - challenges.RequestRateMonitor - challenges.Challenges - wow.Query -} - -type CommandHandler interface { - Handle(ctx context.Context, con networking.Session) error -} - -type commandHandler struct { - CommandHandlerDeps - logger *slog.Logger -} - -func (h *commandHandler) trace(ctx context.Context, msg string, args ...any) { - h.logger.DebugContext(ctx, msg, args...) -} - -func NewHandler(deps CommandHandlerDeps) CommandHandler { - return &commandHandler{ - CommandHandlerDeps: deps, - logger: deps.RootLogger.WithGroup("tcp.server.handler"), - } -} - -func (h *commandHandler) performChallengeVerification( - ctx context.Context, - con networking.Session, - monitoringResult challenges.RecordRequestResult, -) (bool, error) { - var challenge string - challenge, err := h.Challenges.GenerateNewChallenge(con.ClientID()) - if err != nil { - return false, fmt.Errorf("failed to generate new challenge: %w", err) - } - - h.trace(ctx, "Requiring to solve challenge", slog.Int("complexity", monitoringResult.ChallengeComplexity)) - challengeData := fmt.Sprintf("CHALLENGE_REQUIRED: %s;%d", challenge, monitoringResult.ChallengeComplexity) - if err = con.WriteLine(challengeData); err != nil { - return false, err - } - var cmd string - if cmd, err = con.ReadLine(); err != nil { - return false, err - } - if strings.Index(cmd, "CHALLENGE_RESULT:") != 0 { - h.trace(ctx, "Got unexpected challenge result", slog.String("data", cmd)) - return false, con.WriteLine("ERR: UNEXPECTED_CHALLENGE_RESULT") - } - - if !h.Challenges.VerifySolution( - monitoringResult.ChallengeComplexity, - challenge, - strings.Trim(cmd[len("CHALLENGE_RESULT:"):], " "), - ) { - h.trace(ctx, "Challenge verification failed", slog.String("data", cmd)) - return false, con.WriteLine("ERR: CHALLENGE_VERIFICATION_FAILED") - } - return true, nil -} - -func (h *commandHandler) Handle(ctx context.Context, con networking.Session) error { - cmd, err := con.ReadLine() - if err != nil { - return err - } - - // If we need to extend it to support multiple commands - // then this will need to be refactored roughly as follows: - // - new Commands component is added that implement all various commands - // - the HandleCommands will read the command from the connection, and forward the processing to particular - // command implementation - // Keeping it simple for now since we need just a single command. - if cmd != "GET_WOW" { - h.trace(ctx, "Got bad command", slog.String("cmd", cmd)) - return con.WriteLine("ERR: BAD_CMD") - } - - monitoringResult, err := h.RequestRateMonitor.RecordRequest(ctx, con.ClientID()) - if err != nil { - h.logger.ErrorContext(ctx, - "Failed to record request", - slog.String("clientID", con.ClientID()), - diag.ErrAttr(err), - ) - return con.WriteLine("ERR: INTERNAL_ERROR") - } - - if monitoringResult.ChallengeRequired { - var ok bool - ok, err = h.performChallengeVerification(ctx, con, monitoringResult) - if err != nil { - return err - } - if !ok { - return nil - } - } - - wow, err := h.Query.GetNextWoW(ctx) - if err != nil { - return fmt.Errorf("failed to get next wow: %w", err) - } - - h.trace(ctx, "Responding with WOW") - return con.WriteLine("WOW: " + wow) -} diff --git a/pkg/api/tcp/commands/handler_test.go b/pkg/api/tcp/commands/handler_test.go deleted file mode 100644 index 01ca419..0000000 --- a/pkg/api/tcp/commands/handler_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "math/rand/v2" - "testing" - "word-of-wisdom-go/pkg/app/challenges" - "word-of-wisdom-go/pkg/app/wow" - "word-of-wisdom-go/pkg/diag" - "word-of-wisdom-go/pkg/services/networking" - - "github.com/go-faker/faker/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -func TestCommands(t *testing.T) { - makeMockDeps := func(t *testing.T) CommandHandlerDeps { - return CommandHandlerDeps{ - RootLogger: diag.RootTestLogger(), - RequestRateMonitor: challenges.NewMockRequestRateMonitor(t), - Challenges: challenges.NewMockChallenges(t), - Query: wow.NewMockQuery(t), - } - } - - t.Run("Handle", func(t *testing.T) { - t.Run("should fail if err getting command", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - wantErr := errors.New(faker.Sentence()) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - session.MockSetNextError(wantErr) - session.MockSendLine(faker.Word()) - assert.ErrorIs(t, <-handleErr, wantErr) - }) - t.Run("should fail if unexpected command", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - result := session.MockSendLineAndWaitResult(faker.Word()) - assert.Equal(t, "ERR: BAD_CMD", result) - assert.NoError(t, <-handleErr) - }) - t.Run("should process GET_WOW if no challenge required", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - mockMonitor, _ := deps.RequestRateMonitor.(*challenges.MockRequestRateMonitor) - mockMonitor.EXPECT().RecordRequest(ctx, session.ClientID()).Return( - challenges.RecordRequestResult{}, nil, - ) - - wantWow := faker.Sentence() - mockQuery, _ := deps.Query.(*wow.MockQuery) - mockQuery.EXPECT().GetNextWoW(ctx).Return(wantWow, nil) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - result := session.MockSendLineAndWaitResult("GET_WOW") - assert.Equal(t, "WOW: "+wantWow, result) - assert.NoError(t, <-handleErr) - }) - t.Run("should process GET_WOW with challenge required", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - mockMonitor, _ := deps.RequestRateMonitor.(*challenges.MockRequestRateMonitor) - monitorResult := challenges.RecordRequestResult{ - ChallengeRequired: true, - ChallengeComplexity: 5 + rand.IntN(10), - } - mockMonitor.EXPECT().RecordRequest(ctx, session.ClientID()).Return( - monitorResult, nil, - ) - - wantChallenge := faker.UUIDHyphenated() - wantSolution := faker.UUIDHyphenated() - mockChallenges, _ := deps.Challenges.(*challenges.MockChallenges) - mockChallenges.EXPECT().GenerateNewChallenge(session.ClientID()).Return(wantChallenge, nil) - mockChallenges.EXPECT().VerifySolution( - monitorResult.ChallengeComplexity, - wantChallenge, - wantSolution, - ).Return(true) - - wantWow := faker.Sentence() - mockQuery, _ := deps.Query.(*wow.MockQuery) - mockQuery.EXPECT().GetNextWoW(ctx).Return(wantWow, nil) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - result := session.MockSendLineAndWaitResult("GET_WOW") - assert.Equal(t, fmt.Sprintf("CHALLENGE_REQUIRED: %s;%d", wantChallenge, monitorResult.ChallengeComplexity), result) - - result = session.MockSendLineAndWaitResult("CHALLENGE_RESULT: " + wantSolution) - assert.Equal(t, "WOW: "+wantWow, result) - assert.NoError(t, <-handleErr) - }) - t.Run("should fail if record request error", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - mockMonitor, _ := deps.RequestRateMonitor.(*challenges.MockRequestRateMonitor) - mockMonitor.EXPECT().RecordRequest(ctx, session.ClientID()).Return( - challenges.RecordRequestResult{}, errors.New(faker.Sentence()), - ) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - result := session.MockSendLineAndWaitResult("GET_WOW") - assert.Equal(t, "ERR: INTERNAL_ERROR", result) - assert.NoError(t, <-handleErr) - }) - t.Run("should handle get next wow query error", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - mockMonitor, _ := deps.RequestRateMonitor.(*challenges.MockRequestRateMonitor) - mockMonitor.EXPECT().RecordRequest(ctx, session.ClientID()).Return( - challenges.RecordRequestResult{}, nil, - ) - - mockQuery, _ := deps.Query.(*wow.MockQuery) - wantErr := errors.New(faker.Sentence()) - mockQuery.EXPECT().GetNextWoW(ctx).Return("", wantErr) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - session.MockSendLine("GET_WOW") - assert.ErrorIs(t, <-handleErr, wantErr) - }) - t.Run("should handle challenge generation errors", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - mockMonitor, _ := deps.RequestRateMonitor.(*challenges.MockRequestRateMonitor) - mockMonitor.EXPECT().RecordRequest(ctx, session.ClientID()).Return( - challenges.RecordRequestResult{ChallengeRequired: true}, nil, - ) - - wantErr := errors.New(faker.Sentence()) - mockChallenges, _ := deps.Challenges.(*challenges.MockChallenges) - mockChallenges.EXPECT().GenerateNewChallenge(session.ClientID()).Return("", wantErr) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - session.MockSendLine("GET_WOW") - assert.ErrorIs(t, <-handleErr, wantErr) - }) - t.Run("should handle challenge verification error", func(t *testing.T) { - ctx := context.Background() - deps := makeMockDeps(t) - h := NewHandler(deps) - - session := networking.NewMockSession() - - mockMonitor, _ := deps.RequestRateMonitor.(*challenges.MockRequestRateMonitor) - monitorResult := challenges.RecordRequestResult{ - ChallengeRequired: true, - ChallengeComplexity: 5 + rand.IntN(10), - } - mockMonitor.EXPECT().RecordRequest(ctx, session.ClientID()).Return( - monitorResult, nil, - ) - - mockChallenges, _ := deps.Challenges.(*challenges.MockChallenges) - mockChallenges.EXPECT().GenerateNewChallenge(session.ClientID()).Return(faker.Word(), nil) - mockChallenges.EXPECT().VerifySolution( - mock.Anything, - mock.Anything, - mock.Anything, - ).Return(false) - - handleErr := make(chan error) - go func() { - handleErr <- h.Handle(ctx, session) - }() - - session.MockSendLineAndWaitResult("GET_WOW") - - result := session.MockSendLineAndWaitResult("CHALLENGE_RESULT: " + faker.Word()) - assert.Equal(t, "ERR: CHALLENGE_VERIFICATION_FAILED", result) - assert.NoError(t, <-handleErr) - }) - }) -} diff --git a/pkg/api/tcp/commands/mock_command_handler.go b/pkg/api/tcp/commands/mock_command_handler.go deleted file mode 100644 index ba00815..0000000 --- a/pkg/api/tcp/commands/mock_command_handler.go +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by mockery. DO NOT EDIT. - -//go:build !release - -package commands - -import ( - context "context" - networking "word-of-wisdom-go/pkg/services/networking" - - mock "github.com/stretchr/testify/mock" -) - -// MockCommandHandler is an autogenerated mock type for the CommandHandler type -type MockCommandHandler struct { - mock.Mock -} - -type MockCommandHandler_Expecter struct { - mock *mock.Mock -} - -func (_m *MockCommandHandler) EXPECT() *MockCommandHandler_Expecter { - return &MockCommandHandler_Expecter{mock: &_m.Mock} -} - -// Handle provides a mock function with given fields: ctx, con -func (_m *MockCommandHandler) Handle(ctx context.Context, con networking.Session) error { - ret := _m.Called(ctx, con) - - if len(ret) == 0 { - panic("no return value specified for Handle") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, networking.Session) error); ok { - r0 = rf(ctx, con) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockCommandHandler_Handle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Handle' -type MockCommandHandler_Handle_Call struct { - *mock.Call -} - -// Handle is a helper method to define mock.On call -// - ctx context.Context -// - con networking.Session -func (_e *MockCommandHandler_Expecter) Handle(ctx interface{}, con interface{}) *MockCommandHandler_Handle_Call { - return &MockCommandHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, con)} -} - -func (_c *MockCommandHandler_Handle_Call) Run(run func(ctx context.Context, con networking.Session)) *MockCommandHandler_Handle_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(networking.Session)) - }) - return _c -} - -func (_c *MockCommandHandler_Handle_Call) Return(_a0 error) *MockCommandHandler_Handle_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockCommandHandler_Handle_Call) RunAndReturn(run func(context.Context, networking.Session) error) *MockCommandHandler_Handle_Call { - _c.Call.Return(run) - return _c -} - -// NewMockCommandHandler creates a new instance of MockCommandHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockCommandHandler(t interface { - mock.TestingT - Cleanup(func()) -}) *MockCommandHandler { - mock := &MockCommandHandler{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/app/wow/mock_query.go b/pkg/app/wow/mock_query.go deleted file mode 100644 index 9ca5ca5..0000000 --- a/pkg/app/wow/mock_query.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated by mockery. DO NOT EDIT. - -//go:build !release - -package wow - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" -) - -// MockQuery is an autogenerated mock type for the Query type -type MockQuery struct { - mock.Mock -} - -type MockQuery_Expecter struct { - mock *mock.Mock -} - -func (_m *MockQuery) EXPECT() *MockQuery_Expecter { - return &MockQuery_Expecter{mock: &_m.Mock} -} - -// GetNextWoW provides a mock function with given fields: ctx -func (_m *MockQuery) GetNextWoW(ctx context.Context) (string, error) { - ret := _m.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GetNextWoW") - } - - var r0 string - var r1 error - if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { - return rf(ctx) - } - if rf, ok := ret.Get(0).(func(context.Context) string); ok { - r0 = rf(ctx) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = rf(ctx) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockQuery_GetNextWoW_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNextWoW' -type MockQuery_GetNextWoW_Call struct { - *mock.Call -} - -// GetNextWoW is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockQuery_Expecter) GetNextWoW(ctx interface{}) *MockQuery_GetNextWoW_Call { - return &MockQuery_GetNextWoW_Call{Call: _e.mock.On("GetNextWoW", ctx)} -} - -func (_c *MockQuery_GetNextWoW_Call) Run(run func(ctx context.Context)) *MockQuery_GetNextWoW_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *MockQuery_GetNextWoW_Call) Return(_a0 string, _a1 error) *MockQuery_GetNextWoW_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockQuery_GetNextWoW_Call) RunAndReturn(run func(context.Context) (string, error)) *MockQuery_GetNextWoW_Call { - _c.Call.Return(run) - return _c -} - -// NewMockQuery creates a new instance of MockQuery. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockQuery(t interface { - mock.TestingT - Cleanup(func()) -}) *MockQuery { - mock := &MockQuery{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/services/networking/session.go b/pkg/services/networking/session.go deleted file mode 100644 index e143dea..0000000 --- a/pkg/services/networking/session.go +++ /dev/null @@ -1,43 +0,0 @@ -package networking - -import ( - "bufio" - "io" -) - -type Session interface { - ClientID() string - ReadLine() (string, error) - WriteLine(data string) error -} - -type session struct { - clientID string - stream io.ReadWriter - reader *bufio.Reader -} - -func (s *session) ClientID() string { - return s.clientID -} - -func (s *session) ReadLine() (string, error) { - line, _, err := s.reader.ReadLine() - if err != nil { - return "", err - } - return string(line), nil -} - -func (s *session) WriteLine(data string) error { - _, err := s.stream.Write(append([]byte(data), '\n')) - return err -} - -func NewSession(clientID string, stream io.ReadWriter) Session { - return &session{ - clientID, - stream, - bufio.NewReader(stream), - } -} diff --git a/pkg/services/networking/testing.go b/pkg/services/networking/testing.go deleted file mode 100644 index eef0a81..0000000 --- a/pkg/services/networking/testing.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build !release - -package networking - -import "github.com/go-faker/faker/v4" - -type MockSession struct { - clientID string - readBuffer chan string - writeBuffer chan string - nextError error -} - -func (m *MockSession) ClientID() string { - return m.clientID -} - -func (m *MockSession) MockSendLine(line string) { - go func() { - m.readBuffer <- line - }() -} - -func (m *MockSession) MockSendLineAndWaitResult(line string) string { - go func() { - m.readBuffer <- line - }() - return <-m.writeBuffer -} - -func (m *MockSession) MockWaitResult() string { - return <-m.writeBuffer -} - -func (m *MockSession) MockSetNextError(err error) { - m.nextError = err -} - -func (m *MockSession) ReadLine() (string, error) { - data := <-m.readBuffer - return data, m.nextError -} - -func (m *MockSession) WriteLine(data string) error { - m.writeBuffer <- data - return m.nextError -} - -func NewMockSession() *MockSession { - return &MockSession{ - clientID: faker.UUIDHyphenated(), - readBuffer: make(chan string), - writeBuffer: make(chan string), - } -} diff --git a/pkg/services/testing.go b/pkg/services/testing.go deleted file mode 100644 index c183bc4..0000000 --- a/pkg/services/testing.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build !release - -package services - -import ( - "time" - - "github.com/go-faker/faker/v4" -) - -type MockNow struct { - value time.Time -} - -var _ TimeProvider = &MockNow{} - -func (m *MockNow) SetValue(t time.Time) { - m.value = t -} - -func (m *MockNow) Increment(duration time.Duration) { - m.value = m.value.Add(duration) -} - -func (m *MockNow) Now() time.Time { - return m.value -} - -func NewMockNow() *MockNow { - return &MockNow{ - value: time.UnixMilli(faker.RandomUnixTime()), - } -} - -func MockNowValue(p TimeProvider) time.Time { - mp, ok := p.(*MockNow) - if !ok { - panic("provided TimeProvider is not a MockNow") - } - return mp.value -}