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/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_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 + }) + } +}