diff --git a/internal/handlers/ussd/menuhandler.go b/internal/handlers/ussd/menuhandler.go index b01869b..805dcff 100644 --- a/internal/handlers/ussd/menuhandler.go +++ b/internal/handlers/ussd/menuhandler.go @@ -29,11 +29,6 @@ var ( translationDir = path.Join(scriptDir, "locale") ) -type FSData struct { - Path string - St *state.State -} - // FlagManager handles centralized flag management type FlagManager struct { parser *asm.FlagParser diff --git a/internal/handlers/ussd/menuhandler_test.go b/internal/handlers/ussd/menuhandler_test.go index dd24b20..d0367f0 100644 --- a/internal/handlers/ussd/menuhandler_test.go +++ b/internal/handlers/ussd/menuhandler_test.go @@ -11,7 +11,7 @@ import ( "git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/state" - "git.grassecon.net/urdt/ussd/internal/handlers/ussd/mocks" + "git.grassecon.net/urdt/ussd/internal/mocks" "git.grassecon.net/urdt/ussd/internal/models" "git.grassecon.net/urdt/ussd/internal/utils" "github.com/alecthomas/assert/v2" diff --git a/internal/http/at_session_handler.go b/internal/http/at_session_handler.go index 4a0cafa..53c4ba2 100644 --- a/internal/http/at_session_handler.go +++ b/internal/http/at_session_handler.go @@ -32,6 +32,7 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) if err != nil { logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) ash.writeError(w, 400, err) + return } rqs.Config = cfg rqs.Input, err = rp.GetInput(req) @@ -41,16 +42,14 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) return } - rqs, err = ash.Process(rqs) + rqs, err = ash.Process(rqs) switch err { - case handlers.ErrStorage: - code = 500 - case handlers.ErrEngineInit: - code = 500 - case handlers.ErrEngineExec: + case nil: // set code to 200 if no err + code = 200 + case handlers.ErrStorage, handlers.ErrEngineInit, handlers.ErrEngineExec, handlers.ErrEngineType: code = 500 default: - code = 200 + code = 500 } if code != 200 { diff --git a/internal/http/http_test.go b/internal/http/http_test.go new file mode 100644 index 0000000..48d04ca --- /dev/null +++ b/internal/http/http_test.go @@ -0,0 +1,449 @@ +package http + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "git.defalsify.org/vise.git/engine" + "git.grassecon.net/urdt/ussd/internal/handlers" + "git.grassecon.net/urdt/ussd/internal/mocks/httpmocks" +) + +// invalidRequestType is a custom type to test invalid request scenarios +type invalidRequestType struct{} + +// errorReader is a helper type that always returns an error when Read is called +type errorReader struct{} + +func (e *errorReader) Read(p []byte) (n int, err error) { + return 0, errors.New("read error") +} + +func TestNewATSessionHandler(t *testing.T) { + mockHandler := &httpmocks.MockRequestHandler{} + ash := NewATSessionHandler(mockHandler) + + if ash == nil { + t.Fatal("NewATSessionHandler returned nil") + } + + if ash.SessionHandler == nil { + t.Fatal("SessionHandler is nil") + } +} + +func TestATSessionHandler_ServeHTTP(t *testing.T) { + tests := []struct { + name string + setupMocks func(*httpmocks.MockRequestHandler, *httpmocks.MockRequestParser, *httpmocks.MockEngine) + formData url.Values + expectedStatus int + expectedBody string + }{ + { + name: "Successful request", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + req := rq.(*http.Request) + return req.FormValue("phoneNumber"), nil + } + mrp.GetInputFunc = func(rq any) ([]byte, error) { + req := rq.(*http.Request) + text := req.FormValue("text") + parts := strings.Split(text, "*") + return []byte(parts[len(parts)-1]), nil + } + mh.ProcessFunc = func(rqs handlers.RequestSession) (handlers.RequestSession, error) { + rqs.Continue = true + rqs.Engine = me + return rqs, nil + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + mh.OutputFunc = func(rs handlers.RequestSession) (handlers.RequestSession, error) { return rs, nil } + mh.ResetFunc = func(rs handlers.RequestSession) (handlers.RequestSession, error) { return rs, nil } + me.WriteResultFunc = func(context.Context, io.Writer) (int, error) { return 0, nil } + }, + formData: url.Values{ + "phoneNumber": []string{"+1234567890"}, + "text": []string{"1*2*3"}, + }, + expectedStatus: http.StatusOK, + expectedBody: "CON ", + }, + { + name: "GetSessionId error", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + return "", errors.New("no phone number found") + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + }, + formData: url.Values{ + "text": []string{"1*2*3"}, + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "", + }, + { + name: "GetInput error", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + req := rq.(*http.Request) + return req.FormValue("phoneNumber"), nil + } + mrp.GetInputFunc = func(rq any) ([]byte, error) { + return nil, errors.New("no input found") + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + }, + formData: url.Values{ + "phoneNumber": []string{"+1234567890"}, + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "", + }, + { + name: "Process error", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + req := rq.(*http.Request) + return req.FormValue("phoneNumber"), nil + } + mrp.GetInputFunc = func(rq any) ([]byte, error) { + req := rq.(*http.Request) + text := req.FormValue("text") + parts := strings.Split(text, "*") + return []byte(parts[len(parts)-1]), nil + } + mh.ProcessFunc = func(rqs handlers.RequestSession) (handlers.RequestSession, error) { + return rqs, handlers.ErrStorage + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + }, + formData: url.Values{ + "phoneNumber": []string{"+1234567890"}, + "text": []string{"1*2*3"}, + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHandler := &httpmocks.MockRequestHandler{} + mockRequestParser := &httpmocks.MockRequestParser{} + mockEngine := &httpmocks.MockEngine{} + tt.setupMocks(mockHandler, mockRequestParser, mockEngine) + + ash := NewATSessionHandler(mockHandler) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + ash.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + + if tt.expectedBody != "" && w.Body.String() != tt.expectedBody { + t.Errorf("Expected body %q, got %q", tt.expectedBody, w.Body.String()) + } + }) + } +} + +func TestATSessionHandler_Output(t *testing.T) { + tests := []struct { + name string + input handlers.RequestSession + expectedPrefix string + expectedError bool + }{ + { + name: "Continue true", + input: handlers.RequestSession{ + Continue: true, + Engine: &httpmocks.MockEngine{ + WriteResultFunc: func(context.Context, io.Writer) (int, error) { + return 0, nil + }, + }, + Writer: &httpmocks.MockWriter{}, + }, + expectedPrefix: "CON ", + expectedError: false, + }, + { + name: "Continue false", + input: handlers.RequestSession{ + Continue: false, + Engine: &httpmocks.MockEngine{ + WriteResultFunc: func(context.Context, io.Writer) (int, error) { + return 0, nil + }, + }, + Writer: &httpmocks.MockWriter{}, + }, + expectedPrefix: "END ", + expectedError: false, + }, + { + name: "WriteResult error", + input: handlers.RequestSession{ + Continue: true, + Engine: &httpmocks.MockEngine{ + WriteResultFunc: func(context.Context, io.Writer) (int, error) { + return 0, errors.New("write error") + }, + }, + Writer: &httpmocks.MockWriter{}, + }, + expectedPrefix: "CON ", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ash := &ATSessionHandler{} + _, err := ash.Output(tt.input) + + if tt.expectedError && err == nil { + t.Error("Expected an error, but got nil") + } + + if !tt.expectedError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + mw := tt.input.Writer.(*httpmocks.MockWriter) + if !mw.WriteStringCalled { + t.Error("WriteString was not called") + } + + if mw.WrittenString != tt.expectedPrefix { + t.Errorf("Expected prefix %q, got %q", tt.expectedPrefix, mw.WrittenString) + } + }) + } +} + +func TestSessionHandler_ServeHTTP(t *testing.T) { + tests := []struct { + name string + sessionID string + input []byte + parserErr error + processErr error + outputErr error + resetErr error + expectedStatus int + }{ + { + name: "Success", + sessionID: "123", + input: []byte("test input"), + expectedStatus: http.StatusOK, + }, + { + name: "Missing Session ID", + sessionID: "", + parserErr: handlers.ErrSessionMissing, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Process Error", + sessionID: "123", + input: []byte("test input"), + processErr: handlers.ErrStorage, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "Output Error", + sessionID: "123", + input: []byte("test input"), + outputErr: errors.New("output error"), + expectedStatus: http.StatusOK, + }, + { + name: "Reset Error", + sessionID: "123", + input: []byte("test input"), + resetErr: errors.New("reset error"), + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRequestParser := &httpmocks.MockRequestParser{ + GetSessionIdFunc: func(any) (string, error) { + return tt.sessionID, tt.parserErr + }, + GetInputFunc: func(any) ([]byte, error) { + return tt.input, nil + }, + } + + mockRequestHandler := &httpmocks.MockRequestHandler{ + ProcessFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { + return rs, tt.processErr + }, + OutputFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { + return rs, tt.outputErr + }, + ResetFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { + return rs, tt.resetErr + }, + GetRequestParserFunc: func() handlers.RequestParser { + return mockRequestParser + }, + GetConfigFunc: func() engine.Config { + return engine.Config{} + }, + } + + sessionHandler := ToSessionHandler(mockRequestHandler) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(tt.input)) + req.Header.Set("X-Vise-Session", tt.sessionID) + + rr := httptest.NewRecorder() + + sessionHandler.ServeHTTP(rr, req) + + if status := rr.Code; status != tt.expectedStatus { + t.Errorf("handler returned wrong status code: got %v want %v", + status, tt.expectedStatus) + } + }) + } +} + +func TestSessionHandler_writeError(t *testing.T) { + handler := &SessionHandler{} + mockWriter := &httpmocks.MockWriter{} + err := errors.New("test error") + + handler.writeError(mockWriter, http.StatusBadRequest, err) + + if mockWriter.WrittenString != "" { + t.Errorf("Expected empty body, got %s", mockWriter.WrittenString) + } +} + +func TestDefaultRequestParser_GetSessionId(t *testing.T) { + tests := []struct { + name string + request any + expectedID string + expectedError error + }{ + { + name: "Valid Session ID", + request: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("X-Vise-Session", "123456") + return req + }(), + expectedID: "123456", + expectedError: nil, + }, + { + name: "Missing Session ID", + request: httptest.NewRequest(http.MethodPost, "/", nil), + expectedID: "", + expectedError: handlers.ErrSessionMissing, + }, + { + name: "Invalid Request Type", + request: invalidRequestType{}, + expectedID: "", + expectedError: handlers.ErrInvalidRequest, + }, + } + + parser := &DefaultRequestParser{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := parser.GetSessionId(tt.request) + + if id != tt.expectedID { + t.Errorf("Expected session ID %s, got %s", tt.expectedID, id) + } + + if err != tt.expectedError { + t.Errorf("Expected error %v, got %v", tt.expectedError, err) + } + }) + } +} + +func TestDefaultRequestParser_GetInput(t *testing.T) { + tests := []struct { + name string + request any + expectedInput []byte + expectedError error + }{ + { + name: "Valid Input", + request: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test input")) + }(), + expectedInput: []byte("test input"), + expectedError: nil, + }, + { + name: "Empty Input", + request: httptest.NewRequest(http.MethodPost, "/", nil), + expectedInput: []byte{}, + expectedError: nil, + }, + { + name: "Invalid Request Type", + request: invalidRequestType{}, + expectedInput: nil, + expectedError: handlers.ErrInvalidRequest, + }, + { + name: "Read Error", + request: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/", &errorReader{}) + }(), + expectedInput: nil, + expectedError: errors.New("read error"), + }, + } + + parser := &DefaultRequestParser{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, err := parser.GetInput(tt.request) + + if !bytes.Equal(input, tt.expectedInput) { + t.Errorf("Expected input %s, got %s", tt.expectedInput, input) + } + + if err != tt.expectedError && (err == nil || err.Error() != tt.expectedError.Error()) { + t.Errorf("Expected error %v, got %v", tt.expectedError, err) + } + }) + } +} diff --git a/internal/handlers/ussd/mocks/dbmock.go b/internal/mocks/dbmock.go similarity index 100% rename from internal/handlers/ussd/mocks/dbmock.go rename to internal/mocks/dbmock.go diff --git a/internal/mocks/httpmocks/enginemock.go b/internal/mocks/httpmocks/enginemock.go new file mode 100644 index 0000000..d5c6b20 --- /dev/null +++ b/internal/mocks/httpmocks/enginemock.go @@ -0,0 +1,30 @@ +package httpmocks + +import ( + "context" + "io" +) + +// MockEngine implements the engine.Engine interface for testing +type MockEngine struct { + InitFunc func(context.Context) (bool, error) + ExecFunc func(context.Context, []byte) (bool, error) + WriteResultFunc func(context.Context, io.Writer) (int, error) + FinishFunc func() error +} + +func (m *MockEngine) Init(ctx context.Context) (bool, error) { + return m.InitFunc(ctx) +} + +func (m *MockEngine) Exec(ctx context.Context, input []byte) (bool, error) { + return m.ExecFunc(ctx, input) +} + +func (m *MockEngine) WriteResult(ctx context.Context, w io.Writer) (int, error) { + return m.WriteResultFunc(ctx, w) +} + +func (m *MockEngine) Finish() error { + return m.FinishFunc() +} diff --git a/internal/mocks/httpmocks/requesthandlermock.go b/internal/mocks/httpmocks/requesthandlermock.go new file mode 100644 index 0000000..f17abce --- /dev/null +++ b/internal/mocks/httpmocks/requesthandlermock.go @@ -0,0 +1,47 @@ +package httpmocks + +import ( + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/persist" + "git.defalsify.org/vise.git/resource" + "git.grassecon.net/urdt/ussd/internal/handlers" +) + +// MockRequestHandler implements handlers.RequestHandler interface for testing +type MockRequestHandler struct { + ProcessFunc func(handlers.RequestSession) (handlers.RequestSession, error) + GetConfigFunc func() engine.Config + GetEngineFunc func(cfg engine.Config, rs resource.Resource, pe *persist.Persister) engine.Engine + OutputFunc func(rs handlers.RequestSession) (handlers.RequestSession, error) + ResetFunc func(rs handlers.RequestSession) (handlers.RequestSession, error) + ShutdownFunc func() + GetRequestParserFunc func() handlers.RequestParser +} + +func (m *MockRequestHandler) Process(rqs handlers.RequestSession) (handlers.RequestSession, error) { + return m.ProcessFunc(rqs) +} + +func (m *MockRequestHandler) GetConfig() engine.Config { + return m.GetConfigFunc() +} + +func (m *MockRequestHandler) GetEngine(cfg engine.Config, rs resource.Resource, pe *persist.Persister) engine.Engine { + return m.GetEngineFunc(cfg, rs, pe) +} + +func (m *MockRequestHandler) Output(rs handlers.RequestSession) (handlers.RequestSession, error) { + return m.OutputFunc(rs) +} + +func (m *MockRequestHandler) Reset(rs handlers.RequestSession) (handlers.RequestSession, error) { + return m.ResetFunc(rs) +} + +func (m *MockRequestHandler) Shutdown() { + m.ShutdownFunc() +} + +func (m *MockRequestHandler) GetRequestParser() handlers.RequestParser { + return m.GetRequestParserFunc() +} diff --git a/internal/mocks/httpmocks/requestparsermock.go b/internal/mocks/httpmocks/requestparsermock.go new file mode 100644 index 0000000..54b16bf --- /dev/null +++ b/internal/mocks/httpmocks/requestparsermock.go @@ -0,0 +1,15 @@ +package httpmocks + +// MockRequestParser implements the handlers.RequestParser interface for testing +type MockRequestParser struct { + GetSessionIdFunc func(any) (string, error) + GetInputFunc func(any) ([]byte, error) +} + +func (m *MockRequestParser) GetSessionId(rq any) (string, error) { + return m.GetSessionIdFunc(rq) +} + +func (m *MockRequestParser) GetInput(rq any) ([]byte, error) { + return m.GetInputFunc(rq) +} diff --git a/internal/mocks/httpmocks/writermock.go b/internal/mocks/httpmocks/writermock.go new file mode 100644 index 0000000..0d171d2 --- /dev/null +++ b/internal/mocks/httpmocks/writermock.go @@ -0,0 +1,25 @@ +package httpmocks + +import "net/http" + +// MockWriter implements a mock io.Writer for testing +type MockWriter struct { + WriteStringCalled bool + WrittenString string +} + +func (m *MockWriter) Write(p []byte) (n int, err error) { + return len(p), nil +} + +func (m *MockWriter) WriteString(s string) (n int, err error) { + m.WriteStringCalled = true + m.WrittenString = s + return len(s), nil +} + +func (m *MockWriter) Header() http.Header { + return http.Header{} +} + +func (m *MockWriter) WriteHeader(statusCode int) {} \ No newline at end of file diff --git a/internal/handlers/ussd/mocks/servicemock.go b/internal/mocks/servicemock.go similarity index 100% rename from internal/handlers/ussd/mocks/servicemock.go rename to internal/mocks/servicemock.go diff --git a/internal/handlers/ussd/mocks/userdbmock.go b/internal/mocks/userdbmock.go similarity index 100% rename from internal/handlers/ussd/mocks/userdbmock.go rename to internal/mocks/userdbmock.go