diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..877ad49 --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,11 @@ +with-expecter: true +dir: "{{.InterfaceDir}}/mocks" +filename: "mock_{{.InterfaceName | snakecase}}.go" +mockname: "Mock{{.InterfaceName}}" +outpkg: "mocks" +packages: + hash-of-wisdom/internal/service: + interfaces: + QuoteService: + ChallengeGenerator: + ChallengeVerifier: diff --git a/Taskfile.yml b/Taskfile.yml index 1e0015a..8759987 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -74,7 +74,7 @@ tasks: mocks: desc: Generate mocks using mockery cmds: - - mockery + - go run github.com/vektra/mockery/v2@latest check: desc: Run all checks (fmt, build, test, lint) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 1f5c883..fda7242 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -46,21 +46,25 @@ - [X] Implement quote fetching with HTTP client - [X] Add basic error handling -## Phase 3: Basic Server Architecture -- [ ] Set up dependency injection framework (wire/dig) -- [ ] Create core interfaces and contracts +## Phase 3: Service Layer Implementation +**Goal**: Complete service layer with DI for handling requests (untied from TCP presentation) + +- [X] Create service layer interfaces and contracts +- [X] Implement quote request service workflow +- [X] Integrate PoW challenge generation and verification +- [X] Set up simple dependency wiring +- [X] Implement full request-response cycle +- [X] Add comprehensive service layer tests +- [X] Add error handling and validation +- [X] Create public concrete WisdomService type +- [X] Add workflow tests with real PoW implementations +- [X] Add unsuccessful flow tests for invalid solutions + +## Phase 4: Basic Server Architecture - [ ] Set up structured logging (zerolog/logrus) - [ ] Set up metrics collection (prometheus) - [ ] Create configuration management -- [ ] Integrate PoW and quote packages into server architecture - -## Phase 4: Quote Management System -- [ ] Define quote storage interface -- [ ] Implement in-memory quote repository (fake) -- [ ] Create quote selection service (random) -- [ ] Load initial quote collection from file/config -- [ ] Add quote validation and sanitization -- [ ] Write unit tests for quote management +- [ ] Integrate all components into server architecture ## Phase 5: TCP Protocol Implementation - [ ] Implement binary message protocol codec diff --git a/go.mod b/go.mod index fa043f0..77507a5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,18 @@ module hash-of-wisdom go 1.24.3 require ( - github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/stretchr/testify v1.10.0 // indirect - golang.org/x/net v0.33.0 // indirect + github.com/go-resty/resty/v2 v2.16.5 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/text v0.2.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/time v0.8.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5937905..2677afe 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,26 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/quotes/quote.go b/internal/quotes/quote.go new file mode 100644 index 0000000..06746c2 --- /dev/null +++ b/internal/quotes/quote.go @@ -0,0 +1,6 @@ +package quotes + +type Quote struct { + Text string `json:"text"` + Author string `json:"author"` +} diff --git a/internal/quotes/types.go b/internal/quotes/types.go deleted file mode 100644 index f7ae234..0000000 --- a/internal/quotes/types.go +++ /dev/null @@ -1,12 +0,0 @@ -package quotes - -import "context" - -type Quote struct { - Text string `json:"text"` - Author string `json:"author"` -} - -type Service interface { - GetRandomQuote(ctx context.Context) (*Quote, error) -} diff --git a/internal/service/adapters.go b/internal/service/adapters.go new file mode 100644 index 0000000..a789c04 --- /dev/null +++ b/internal/service/adapters.go @@ -0,0 +1,16 @@ +package service + +import "hash-of-wisdom/internal/pow/challenge" + +// generatorAdapter adapts the real challenge.Generator to our interface +type generatorAdapter struct { + generator *challenge.Generator +} + +func NewGeneratorAdapter(generator *challenge.Generator) ChallengeGenerator { + return &generatorAdapter{generator: generator} +} + +func (a *generatorAdapter) GenerateChallenge() (*challenge.Challenge, error) { + return a.generator.GenerateChallenge() +} diff --git a/internal/service/mocks/mock_challenge_generator.go b/internal/service/mocks/mock_challenge_generator.go new file mode 100644 index 0000000..b27d522 --- /dev/null +++ b/internal/service/mocks/mock_challenge_generator.go @@ -0,0 +1,93 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + challenge "hash-of-wisdom/internal/pow/challenge" + + mock "github.com/stretchr/testify/mock" +) + +// MockChallengeGenerator is an autogenerated mock type for the ChallengeGenerator type +type MockChallengeGenerator struct { + mock.Mock +} + +type MockChallengeGenerator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockChallengeGenerator) EXPECT() *MockChallengeGenerator_Expecter { + return &MockChallengeGenerator_Expecter{mock: &_m.Mock} +} + +// GenerateChallenge provides a mock function with no fields +func (_m *MockChallengeGenerator) GenerateChallenge() (*challenge.Challenge, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GenerateChallenge") + } + + var r0 *challenge.Challenge + var r1 error + if rf, ok := ret.Get(0).(func() (*challenge.Challenge, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *challenge.Challenge); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*challenge.Challenge) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockChallengeGenerator_GenerateChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateChallenge' +type MockChallengeGenerator_GenerateChallenge_Call struct { + *mock.Call +} + +// GenerateChallenge is a helper method to define mock.On call +func (_e *MockChallengeGenerator_Expecter) GenerateChallenge() *MockChallengeGenerator_GenerateChallenge_Call { + return &MockChallengeGenerator_GenerateChallenge_Call{Call: _e.mock.On("GenerateChallenge")} +} + +func (_c *MockChallengeGenerator_GenerateChallenge_Call) Run(run func()) *MockChallengeGenerator_GenerateChallenge_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockChallengeGenerator_GenerateChallenge_Call) Return(_a0 *challenge.Challenge, _a1 error) *MockChallengeGenerator_GenerateChallenge_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockChallengeGenerator_GenerateChallenge_Call) RunAndReturn(run func() (*challenge.Challenge, error)) *MockChallengeGenerator_GenerateChallenge_Call { + _c.Call.Return(run) + return _c +} + +// NewMockChallengeGenerator creates a new instance of MockChallengeGenerator. 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 NewMockChallengeGenerator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockChallengeGenerator { + mock := &MockChallengeGenerator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/service/mocks/mock_challenge_verifier.go b/internal/service/mocks/mock_challenge_verifier.go new file mode 100644 index 0000000..a6f0f87 --- /dev/null +++ b/internal/service/mocks/mock_challenge_verifier.go @@ -0,0 +1,82 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + challenge "hash-of-wisdom/internal/pow/challenge" + + mock "github.com/stretchr/testify/mock" +) + +// MockChallengeVerifier is an autogenerated mock type for the ChallengeVerifier type +type MockChallengeVerifier struct { + mock.Mock +} + +type MockChallengeVerifier_Expecter struct { + mock *mock.Mock +} + +func (_m *MockChallengeVerifier) EXPECT() *MockChallengeVerifier_Expecter { + return &MockChallengeVerifier_Expecter{mock: &_m.Mock} +} + +// VerifyChallenge provides a mock function with given fields: ch +func (_m *MockChallengeVerifier) VerifyChallenge(ch *challenge.Challenge) error { + ret := _m.Called(ch) + + if len(ret) == 0 { + panic("no return value specified for VerifyChallenge") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*challenge.Challenge) error); ok { + r0 = rf(ch) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockChallengeVerifier_VerifyChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyChallenge' +type MockChallengeVerifier_VerifyChallenge_Call struct { + *mock.Call +} + +// VerifyChallenge is a helper method to define mock.On call +// - ch *challenge.Challenge +func (_e *MockChallengeVerifier_Expecter) VerifyChallenge(ch interface{}) *MockChallengeVerifier_VerifyChallenge_Call { + return &MockChallengeVerifier_VerifyChallenge_Call{Call: _e.mock.On("VerifyChallenge", ch)} +} + +func (_c *MockChallengeVerifier_VerifyChallenge_Call) Run(run func(ch *challenge.Challenge)) *MockChallengeVerifier_VerifyChallenge_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*challenge.Challenge)) + }) + return _c +} + +func (_c *MockChallengeVerifier_VerifyChallenge_Call) Return(_a0 error) *MockChallengeVerifier_VerifyChallenge_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockChallengeVerifier_VerifyChallenge_Call) RunAndReturn(run func(*challenge.Challenge) error) *MockChallengeVerifier_VerifyChallenge_Call { + _c.Call.Return(run) + return _c +} + +// NewMockChallengeVerifier creates a new instance of MockChallengeVerifier. 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 NewMockChallengeVerifier(t interface { + mock.TestingT + Cleanup(func()) +}) *MockChallengeVerifier { + mock := &MockChallengeVerifier{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/service/mocks/mock_quote_service.go b/internal/service/mocks/mock_quote_service.go new file mode 100644 index 0000000..194c090 --- /dev/null +++ b/internal/service/mocks/mock_quote_service.go @@ -0,0 +1,95 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + quotes "hash-of-wisdom/internal/quotes" + + mock "github.com/stretchr/testify/mock" +) + +// MockQuoteService is an autogenerated mock type for the QuoteService type +type MockQuoteService struct { + mock.Mock +} + +type MockQuoteService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockQuoteService) EXPECT() *MockQuoteService_Expecter { + return &MockQuoteService_Expecter{mock: &_m.Mock} +} + +// GetRandomQuote provides a mock function with given fields: ctx +func (_m *MockQuoteService) GetRandomQuote(ctx context.Context) (*quotes.Quote, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetRandomQuote") + } + + var r0 *quotes.Quote + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*quotes.Quote, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *quotes.Quote); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*quotes.Quote) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockQuoteService_GetRandomQuote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRandomQuote' +type MockQuoteService_GetRandomQuote_Call struct { + *mock.Call +} + +// GetRandomQuote is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockQuoteService_Expecter) GetRandomQuote(ctx interface{}) *MockQuoteService_GetRandomQuote_Call { + return &MockQuoteService_GetRandomQuote_Call{Call: _e.mock.On("GetRandomQuote", ctx)} +} + +func (_c *MockQuoteService_GetRandomQuote_Call) Run(run func(ctx context.Context)) *MockQuoteService_GetRandomQuote_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockQuoteService_GetRandomQuote_Call) Return(_a0 *quotes.Quote, _a1 error) *MockQuoteService_GetRandomQuote_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockQuoteService_GetRandomQuote_Call) RunAndReturn(run func(context.Context) (*quotes.Quote, error)) *MockQuoteService_GetRandomQuote_Call { + _c.Call.Return(run) + return _c +} + +// NewMockQuoteService creates a new instance of MockQuoteService. 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 NewMockQuoteService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockQuoteService { + mock := &MockQuoteService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/service/wisdom.go b/internal/service/wisdom.go new file mode 100644 index 0000000..5cc2ca7 --- /dev/null +++ b/internal/service/wisdom.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "errors" + "fmt" + "hash-of-wisdom/internal/pow/challenge" + "hash-of-wisdom/internal/quotes" +) + +var ( + ErrResourceRequired = errors.New("resource is required") + ErrUnsupportedResource = errors.New("unsupported resource") + ErrSolutionRequired = errors.New("solution is required") + ErrInvalidChallenge = errors.New("invalid challenge") + ErrInvalidSolution = errors.New("invalid proof of work solution") +) + +type ChallengeGenerator interface { + GenerateChallenge() (*challenge.Challenge, error) +} + +type ChallengeVerifier interface { + VerifyChallenge(ch *challenge.Challenge) error +} + +type QuoteService interface { + GetRandomQuote(ctx context.Context) (*quotes.Quote, error) +} + +type WisdomService struct { + challengeGenerator ChallengeGenerator + challengeVerifier ChallengeVerifier + quoteService QuoteService +} + +func NewWisdomService( + generator ChallengeGenerator, + verifier ChallengeVerifier, + quoteService QuoteService, +) *WisdomService { + return &WisdomService{ + challengeGenerator: generator, + challengeVerifier: verifier, + quoteService: quoteService, + } +} + +func (s *WisdomService) GenerateChallenge(ctx context.Context, resource string) (*challenge.Challenge, error) { + if resource == "" { + return nil, ErrResourceRequired + } + + if resource != "quotes" { + return nil, ErrUnsupportedResource + } + + ch, err := s.challengeGenerator.GenerateChallenge() + if err != nil { + return nil, fmt.Errorf("failed to generate challenge: %w", err) + } + + return ch, nil +} + +func (s *WisdomService) VerifySolution(ctx context.Context, solution *challenge.Solution) error { + if solution == nil { + return ErrSolutionRequired + } + + // Verify challenge authenticity and expiration + if err := s.challengeVerifier.VerifyChallenge(&solution.Challenge); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidChallenge, err) + } + + // Verify PoW solution + if !solution.Verify() { + return ErrInvalidSolution + } + + return nil +} + +func (s *WisdomService) GetQuote(ctx context.Context) (*quotes.Quote, error) { + return s.quoteService.GetRandomQuote(ctx) +} diff --git a/internal/service/wisdom_test.go b/internal/service/wisdom_test.go new file mode 100644 index 0000000..d98766e --- /dev/null +++ b/internal/service/wisdom_test.go @@ -0,0 +1,186 @@ +package service + +import ( + "context" + "testing" + "time" + + "hash-of-wisdom/internal/pow/challenge" + "hash-of-wisdom/internal/quotes" + "hash-of-wisdom/internal/service/mocks" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestWisdomService_GenerateChallenge(t *testing.T) { + tests := []struct { + name string + resource string + wantErr error + }{ + { + name: "valid resource", + resource: "quotes", + wantErr: nil, + }, + { + name: "empty resource", + resource: "", + wantErr: ErrResourceRequired, + }, + { + name: "unsupported resource", + resource: "invalid", + wantErr: ErrUnsupportedResource, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockGen := mocks.NewMockChallengeGenerator(t) + mockVer := mocks.NewMockChallengeVerifier(t) + mockQuote := mocks.NewMockQuoteService(t) + + service := NewWisdomService(mockGen, mockVer, mockQuote) + + if tt.wantErr == nil { + expectedChallenge := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 4, + Resource: "quotes", + Random: []byte{0x01, 0x02, 0x03}, + } + mockGen.EXPECT().GenerateChallenge().Return(expectedChallenge, nil).Once() + } + + ctx := context.Background() + ch, err := service.GenerateChallenge(ctx, tt.resource) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + assert.Nil(t, ch) + } else { + assert.NoError(t, err) + assert.NotNil(t, ch) + } + }) + } +} + +func TestWisdomService_VerifySolution(t *testing.T) { + tests := []struct { + name string + solution *challenge.Solution + wantErr error + setupMocks func(*mocks.MockChallengeVerifier) + }{ + { + name: "nil solution", + solution: nil, + wantErr: ErrSolutionRequired, + setupMocks: func(mv *mocks.MockChallengeVerifier) {}, + }, + { + name: "invalid challenge", + solution: &challenge.Solution{ + Challenge: challenge.Challenge{}, + Nonce: 123, + }, + wantErr: ErrInvalidChallenge, + setupMocks: func(mv *mocks.MockChallengeVerifier) { + mv.EXPECT().VerifyChallenge(mock.Anything).Return(challenge.ErrInvalidHMAC).Once() + }, + }, + { + name: "invalid solution", + solution: createInvalidSolution(t), + wantErr: ErrInvalidSolution, + setupMocks: func(mv *mocks.MockChallengeVerifier) { + mv.EXPECT().VerifyChallenge(mock.Anything).Return(nil).Once() + }, + }, + { + name: "valid solution", + solution: createValidSolution(t), + wantErr: nil, + setupMocks: func(mv *mocks.MockChallengeVerifier) { + mv.EXPECT().VerifyChallenge(mock.Anything).Return(nil).Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockGen := mocks.NewMockChallengeGenerator(t) + mockVer := mocks.NewMockChallengeVerifier(t) + mockQuote := mocks.NewMockQuoteService(t) + + tt.setupMocks(mockVer) + + service := NewWisdomService(mockGen, mockVer, mockQuote) + + ctx := context.Background() + err := service.VerifySolution(ctx, tt.solution) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestWisdomService_GetQuote(t *testing.T) { + mockGen := mocks.NewMockChallengeGenerator(t) + mockVer := mocks.NewMockChallengeVerifier(t) + mockQuote := mocks.NewMockQuoteService(t) + + expectedQuote := "es.Quote{ + Text: "Test quote", + Author: "Test author", + } + + mockQuote.EXPECT().GetRandomQuote(mock.Anything).Return(expectedQuote, nil).Once() + + service := NewWisdomService(mockGen, mockVer, mockQuote) + + ctx := context.Background() + quote, err := service.GetQuote(ctx) + + assert.NoError(t, err) + assert.Equal(t, expectedQuote, quote) +} + +func createValidSolution(t *testing.T) *challenge.Solution { + config := challenge.TestConfig() + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 0, // Difficulty 0 means any nonce works + Resource: "quotes", + Random: []byte{0x01, 0x02, 0x03}, + } + ch.Sign(config.HMACSecret) + + return &challenge.Solution{ + Challenge: *ch, + Nonce: 0, + } +} + +func createInvalidSolution(t *testing.T) *challenge.Solution { + config := challenge.TestConfig() + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 20, // High difficulty, nonce 999 won't work + Resource: "quotes", + Random: []byte{0x01, 0x02, 0x03}, + } + ch.Sign(config.HMACSecret) + + return &challenge.Solution{ + Challenge: *ch, + Nonce: 999, // Wrong nonce for difficulty 20 + } +} diff --git a/internal/service/workflow_test.go b/internal/service/workflow_test.go new file mode 100644 index 0000000..bd0c356 --- /dev/null +++ b/internal/service/workflow_test.go @@ -0,0 +1,370 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "hash-of-wisdom/internal/pow/challenge" + "hash-of-wisdom/internal/pow/solver" + "hash-of-wisdom/internal/quotes" + "hash-of-wisdom/internal/service/mocks" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWisdomService_FullWorkflowTests(t *testing.T) { + tests := []struct { + name string + resource string + difficulty int + setupQuote func(*mocks.MockQuoteService) + wantErr bool + expectQuote bool + }{ + { + name: "successful flow with difficulty 1", + resource: "quotes", + difficulty: 1, + setupQuote: func(m *mocks.MockQuoteService) { + m.EXPECT().GetRandomQuote(context.Background()).Return("es.Quote{ + Text: "Success quote", + Author: "Test Author", + }, nil).Once() + }, + wantErr: false, + expectQuote: true, + }, + { + name: "successful flow with difficulty 2", + resource: "quotes", + difficulty: 2, + setupQuote: func(m *mocks.MockQuoteService) { + m.EXPECT().GetRandomQuote(context.Background()).Return("es.Quote{ + Text: "Another quote", + Author: "Another Author", + }, nil).Once() + }, + wantErr: false, + expectQuote: true, + }, + { + name: "successful flow with difficulty 3", + resource: "quotes", + difficulty: 3, + setupQuote: func(m *mocks.MockQuoteService) { + m.EXPECT().GetRandomQuote(context.Background()).Return("es.Quote{ + Text: "Hard quote", + Author: "Hard Author", + }, nil).Once() + }, + wantErr: false, + expectQuote: true, + }, + { + name: "invalid resource", + resource: "invalid", + difficulty: 1, + setupQuote: func(m *mocks.MockQuoteService) { + // No expectations - should not reach quote service + }, + wantErr: true, + expectQuote: false, + }, + { + name: "empty resource", + resource: "", + difficulty: 1, + setupQuote: func(m *mocks.MockQuoteService) { + // No expectations - should not reach quote service + }, + wantErr: true, + expectQuote: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create real PoW components + config, err := challenge.NewConfig( + challenge.WithDefaultDifficulty(tt.difficulty), + challenge.WithMinDifficulty(1), + challenge.WithMaxDifficulty(10), + ) + require.NoError(t, err) + + generator := challenge.NewGenerator(config) + verifier := challenge.NewVerifier(config) + powSolver := solver.NewSolver(solver.WithWorkers(2)) + + // Mock quote service + mockQuote := mocks.NewMockQuoteService(t) + tt.setupQuote(mockQuote) + + // Create service + service := NewWisdomService(NewGeneratorAdapter(generator), verifier, mockQuote) + + ctx := context.Background() + + // Step 1: Generate challenge + ch, err := service.GenerateChallenge(ctx, tt.resource) + if tt.wantErr && (tt.resource == "" || tt.resource != "quotes") { + assert.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, ch) + + // Challenge generated successfully (HMAC ensures integrity) + + // Step 2: Solve the challenge + solveCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + solution, err := powSolver.Solve(solveCtx, ch) + require.NoError(t, err) + require.NotNil(t, solution) + + // Step 3: Verify solution through service + err = service.VerifySolution(ctx, solution) + require.NoError(t, err) + + // Step 4: Get quote + if tt.expectQuote { + quote, err := service.GetQuote(ctx) + require.NoError(t, err) + require.NotNil(t, quote) + assert.NotEmpty(t, quote.Text) + assert.NotEmpty(t, quote.Author) + } + }) + } +} + +func TestWisdomService_FullWorkflow_MultipleRounds(t *testing.T) { + // Test multiple consecutive challenge-solve-quote cycles + config, err := challenge.NewConfig( + challenge.WithDefaultDifficulty(2), + challenge.WithMinDifficulty(1), + challenge.WithMaxDifficulty(5), + ) + require.NoError(t, err) + + generator := challenge.NewGenerator(config) + verifier := challenge.NewVerifier(config) + powSolver := solver.NewSolver(solver.WithWorkers(1)) + mockQuote := mocks.NewMockQuoteService(t) + + // Setup expectations for 3 rounds + quotes := []*quotes.Quote{ + {Text: "First quote", Author: "Author 1"}, + {Text: "Second quote", Author: "Author 2"}, + {Text: "Third quote", Author: "Author 3"}, + } + + for _, quote := range quotes { + mockQuote.EXPECT().GetRandomQuote(context.Background()).Return(quote, nil).Once() + } + + service := NewWisdomService(NewGeneratorAdapter(generator), verifier, mockQuote) + ctx := context.Background() + + // Run 3 complete cycles + for i, expectedQuote := range quotes { + t.Run(fmt.Sprintf("round_%d", i+1), func(t *testing.T) { + // Generate challenge + ch, err := service.GenerateChallenge(ctx, "quotes") + require.NoError(t, err) + + // Solve challenge + solveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + solution, err := powSolver.Solve(solveCtx, ch) + cancel() + require.NoError(t, err) + + // Verify solution + err = service.VerifySolution(ctx, solution) + require.NoError(t, err) + + // Get quote + quote, err := service.GetQuote(ctx) + require.NoError(t, err) + assert.Equal(t, expectedQuote.Text, quote.Text) + assert.Equal(t, expectedQuote.Author, quote.Author) + }) + } +} + +func TestWisdomService_InvalidSolutions(t *testing.T) { + config, err := challenge.NewConfig( + challenge.WithDefaultDifficulty(3), + challenge.WithMinDifficulty(1), + challenge.WithMaxDifficulty(10), + ) + require.NoError(t, err) + + generator := challenge.NewGenerator(config) + verifier := challenge.NewVerifier(config) + powSolver := solver.NewSolver(solver.WithWorkers(1)) + mockQuote := mocks.NewMockQuoteService(t) + + service := NewWisdomService(NewGeneratorAdapter(generator), verifier, mockQuote) + ctx := context.Background() + + // Generate a valid challenge + ch, err := service.GenerateChallenge(ctx, "quotes") + require.NoError(t, err) + + // Solve it properly + solveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + validSolution, err := powSolver.Solve(solveCtx, ch) + cancel() + require.NoError(t, err) + + tests := []struct { + name string + solution *challenge.Solution + wantErr error + }{ + { + name: "nil solution", + solution: nil, + wantErr: ErrSolutionRequired, + }, + { + name: "tampered challenge", + solution: &challenge.Solution{ + Challenge: challenge.Challenge{ + Timestamp: ch.Timestamp, + Difficulty: ch.Difficulty + 1, // Tampered + Resource: ch.Resource, + Random: ch.Random, + HMAC: ch.HMAC, // Original HMAC won't match tampered data + }, + Nonce: validSolution.Nonce, + }, + wantErr: ErrInvalidChallenge, + }, + { + name: "wrong nonce", + solution: &challenge.Solution{ + Challenge: *ch, + Nonce: validSolution.Nonce + 1, // Wrong nonce + }, + wantErr: ErrInvalidSolution, + }, + { + name: "expired challenge", + solution: func() *challenge.Solution { + expiredCh := &challenge.Challenge{ + Timestamp: time.Now().Add(-10 * time.Minute).Unix(), // Expired + Difficulty: ch.Difficulty, + Resource: ch.Resource, + Random: ch.Random, + } + expiredCh.Sign(config.HMACSecret) + return &challenge.Solution{ + Challenge: *expiredCh, + Nonce: 0, // Difficulty doesn't matter since it will fail on expiry + } + }(), + wantErr: ErrInvalidChallenge, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := service.VerifySolution(ctx, tt.solution) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestWisdomService_UnsuccessfulFlows(t *testing.T) { + tests := []struct { + name string + difficulty int + createSolution func(*challenge.Challenge, *challenge.Solution) *challenge.Solution + }{ + { + name: "tampered solution", + difficulty: 2, + createSolution: func(ch *challenge.Challenge, validSolution *challenge.Solution) *challenge.Solution { + return &challenge.Solution{ + Challenge: challenge.Challenge{ + Timestamp: ch.Timestamp, + Difficulty: ch.Difficulty + 1, // Tampered + Resource: ch.Resource, + Random: ch.Random, + HMAC: ch.HMAC, // Original HMAC won't match tampered data + }, + Nonce: validSolution.Nonce, + } + }, + }, + { + name: "wrong nonce", + difficulty: 3, + createSolution: func(ch *challenge.Challenge, validSolution *challenge.Solution) *challenge.Solution { + return &challenge.Solution{ + Challenge: *ch, + Nonce: 999999, // Wrong nonce + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create real PoW components + config, err := challenge.NewConfig( + challenge.WithDefaultDifficulty(tt.difficulty), + challenge.WithMinDifficulty(1), + challenge.WithMaxDifficulty(10), + ) + require.NoError(t, err) + + generator := challenge.NewGenerator(config) + verifier := challenge.NewVerifier(config) + powSolver := solver.NewSolver(solver.WithWorkers(2)) + + // Mock quote service - should not be called + mockQuote := mocks.NewMockQuoteService(t) + // No expectations - service should not reach quote fetching + + // Create service + service := NewWisdomService(NewGeneratorAdapter(generator), verifier, mockQuote) + + ctx := context.Background() + + // Step 1: Generate challenge + ch, err := service.GenerateChallenge(ctx, "quotes") + require.NoError(t, err) + require.NotNil(t, ch) + + // Step 2: Get a valid solution first (for tampering tests) + solveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + validSolution, err := powSolver.Solve(solveCtx, ch) + cancel() + require.NoError(t, err) + + // Step 3: Create invalid solution + invalidSolution := tt.createSolution(ch, validSolution) + + // Step 4: Verify solution should fail + err = service.VerifySolution(ctx, invalidSolution) + require.Error(t, err) + + // Verify error type based on test case + if tt.name == "tampered solution" { + require.ErrorIs(t, err, ErrInvalidChallenge) + } else if tt.name == "wrong nonce" { + require.ErrorIs(t, err, ErrInvalidSolution) + } + + // Quote service should never be called in unsuccessful flows + }) + } +}