diff --git a/pkg/restheadspec/empty_result_test.go b/pkg/restheadspec/empty_result_test.go new file mode 100644 index 0000000..e230fd0 --- /dev/null +++ b/pkg/restheadspec/empty_result_test.go @@ -0,0 +1,193 @@ +package restheadspec + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +// Test that normalizeResultArray returns empty array when no records found without ID +func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) { + handler := &Handler{} + + tests := []struct { + name string + input interface{} + shouldBeEmptyArr bool + }{ + { + name: "nil should return empty array", + input: nil, + shouldBeEmptyArr: true, + }, + { + name: "empty slice should return empty array", + input: []*EmptyTestModel{}, + shouldBeEmptyArr: true, + }, + { + name: "single element should return the element", + input: []*EmptyTestModel{{ID: 1, Name: "test"}}, + shouldBeEmptyArr: false, + }, + { + name: "multiple elements should return the slice", + input: []*EmptyTestModel{ + {ID: 1, Name: "test1"}, + {ID: 2, Name: "test2"}, + }, + shouldBeEmptyArr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := handler.normalizeResultArray(tt.input) + + // For cases that should return empty array + if tt.shouldBeEmptyArr { + emptyArr, ok := result.([]interface{}) + if !ok { + t.Errorf("Expected empty array []interface{}{}, got %T: %v", result, result) + return + } + if len(emptyArr) != 0 { + t.Errorf("Expected empty array with length 0, got length %d", len(emptyArr)) + } + + // Verify it serializes to [] and not null + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Errorf("Failed to marshal result: %v", err) + return + } + if string(jsonBytes) != "[]" { + t.Errorf("Expected JSON '[]', got '%s'", string(jsonBytes)) + } + } + }) + } +} + +// Test that sendFormattedResponse adds X-No-Data-Found header +func TestSendFormattedResponse_NoDataFoundHeader(t *testing.T) { + handler := &Handler{} + + // Mock ResponseWriter + mockWriter := &MockTestResponseWriter{ + headers: make(map[string]string), + } + + metadata := &common.Metadata{ + Total: 0, + Count: 0, + Filtered: 0, + Limit: 10, + Offset: 0, + } + + options := ExtendedRequestOptions{ + RequestOptions: common.RequestOptions{}, + } + + // Test with empty data + emptyData := []interface{}{} + handler.sendFormattedResponse(mockWriter, emptyData, metadata, options) + + // Check if X-No-Data-Found header was set + if mockWriter.headers["X-No-Data-Found"] != "true" { + t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"]) + } + + // Verify the body is an empty array + if mockWriter.body == nil { + t.Error("Expected body to be set, got nil") + } else { + bodyBytes, err := json.Marshal(mockWriter.body) + if err != nil { + t.Errorf("Failed to marshal body: %v", err) + } + // The body should be wrapped in a Response object with "data" field + bodyStr := string(bodyBytes) + if !strings.Contains(bodyStr, `"data":[]`) && !strings.Contains(bodyStr, `"result":[]`) { + t.Errorf("Expected body to contain empty array, got: %s", bodyStr) + } + } +} + +// Test that sendResponseWithOptions adds X-No-Data-Found header +func TestSendResponseWithOptions_NoDataFoundHeader(t *testing.T) { + handler := &Handler{} + + // Mock ResponseWriter + mockWriter := &MockTestResponseWriter{ + headers: make(map[string]string), + } + + metadata := &common.Metadata{} + options := &ExtendedRequestOptions{} + + // Test with nil data + handler.sendResponseWithOptions(mockWriter, nil, metadata, options) + + // Check if X-No-Data-Found header was set + if mockWriter.headers["X-No-Data-Found"] != "true" { + t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"]) + } + + // Check status code is 200 + if mockWriter.statusCode != 200 { + t.Errorf("Expected status code 200, got %d", mockWriter.statusCode) + } + + // Verify the body is an empty array + if mockWriter.body == nil { + t.Error("Expected body to be set, got nil") + } else { + bodyBytes, err := json.Marshal(mockWriter.body) + if err != nil { + t.Errorf("Failed to marshal body: %v", err) + } + bodyStr := string(bodyBytes) + if bodyStr != "[]" { + t.Errorf("Expected body to be '[]', got: %s", bodyStr) + } + } +} + +// MockTestResponseWriter for testing +type MockTestResponseWriter struct { + headers map[string]string + statusCode int + body interface{} +} + +func (m *MockTestResponseWriter) SetHeader(key, value string) { + m.headers[key] = value +} + +func (m *MockTestResponseWriter) WriteHeader(statusCode int) { + m.statusCode = statusCode +} + +func (m *MockTestResponseWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +func (m *MockTestResponseWriter) WriteJSON(data interface{}) error { + m.body = data + return nil +} + +func (m *MockTestResponseWriter) UnderlyingResponseWriter() http.ResponseWriter { + return nil +} + +// EmptyTestModel for testing +type EmptyTestModel struct { + ID int64 `json:"id"` + Name string `json:"name"` +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index eda5f4c..5a71a20 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -2143,12 +2143,22 @@ func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metada // sendResponseWithOptions sends a response with optional formatting func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options *ExtendedRequestOptions) { w.SetHeader("Content-Type", "application/json") + + // Handle nil data - convert to empty array if data == nil { - data = map[string]interface{}{} - w.WriteHeader(http.StatusPartialContent) - } else { - w.WriteHeader(http.StatusOK) + data = []interface{}{} } + + // Calculate data length after nil conversion + dataLen := reflection.Len(data) + + // Add X-No-Data-Found header when no records were found + if dataLen == 0 { + w.SetHeader("X-No-Data-Found", "true") + } + + w.WriteHeader(http.StatusOK) + // Normalize single-record arrays to objects if requested if options != nil && options.SingleRecordAsObject { data = h.normalizeResultArray(data) @@ -2165,7 +2175,7 @@ func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interfac // Returns the single element if data is a slice/array with exactly one element, otherwise returns data unchanged func (h *Handler) normalizeResultArray(data interface{}) interface{} { if data == nil { - return map[string]interface{}{} + return []interface{}{} } // Use reflection to check if data is a slice or array @@ -2180,15 +2190,15 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} { // Return the single element return dataValue.Index(0).Interface() } else if dataValue.Len() == 0 { - // Return empty object instead of empty array - return map[string]interface{}{} + // Keep empty array as empty array, don't convert to empty object + return []interface{}{} } } if dataValue.Kind() == reflect.String { str := dataValue.String() if str == "" || str == "null" { - return map[string]interface{}{} + return []interface{}{} } } @@ -2199,16 +2209,25 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} { func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) { // Normalize single-record arrays to objects if requested httpStatus := http.StatusOK + + // Handle nil data - convert to empty array if data == nil { - data = map[string]interface{}{} - httpStatus = http.StatusPartialContent - } else { - dataLen := reflection.Len(data) - if dataLen == 0 { - httpStatus = http.StatusPartialContent - } + data = []interface{}{} } + // Calculate data length after nil conversion + // Note: This is done BEFORE normalization because X-No-Data-Found indicates + // whether data was found in the database, not the final response format + dataLen := reflection.Len(data) + + // Add X-No-Data-Found header when no records were found + if dataLen == 0 { + w.SetHeader("X-No-Data-Found", "true") + } + + // Apply normalization after header is set + // normalizeResultArray may convert single-element arrays to objects, + // but the X-No-Data-Found header reflects the original query result if options.SingleRecordAsObject { data = h.normalizeResultArray(data) }