diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md new file mode 100644 index 0000000..b658aff --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md @@ -0,0 +1,45 @@ +# Context: Request ID and Timeout Middleware + +## Project Structure +- Module: `github.com/parth/smolterms` +- Target package: `backend/internal/api/` +- Key files: `middleware.go`, `router.go` +- Test files: `middleware_test.go`, `router_test.go` + +## Existing Patterns + +### Middleware Pattern +- Simple middleware: `func(http.Handler) http.Handler` (e.g., `CORSMiddleware`) +- Parameterized middleware: returns `func(http.Handler) http.Handler` (e.g., `LoggingMiddleware(logger)`) +- Chain order in `NewRouter`: innermost applied first, outermost wraps last +- Current chain: `mux -> LoggingMiddleware -> CORSMiddleware` + +### Testing Pattern +- Package-level tests (`package api`) +- `httptest.NewRecorder()` + `httptest.NewRequest()` for unit tests +- `httptest.NewServer()` for integration tests (router_test.go) +- Table-driven subtests with `t.Run()` +- `bytes.Buffer` + `slog.NewTextHandler` for capturing log output + +### Response Writer Pattern +- `statusRecorder` wraps `http.ResponseWriter` to capture status code +- Write-once guard on `WriteHeader` + +## Requirements Summary + +1. **Request ID Middleware**: Generate UUID v4, set `X-Request-ID` header, respect incoming header, inject into context +2. **RequestIDFromContext helper**: Extract request ID from context +3. **Timeout Middleware**: `context.WithTimeout` on request context, configurable duration +4. **LoggingMiddleware update**: Include `request_id` in log output +5. **Router update**: Wire middleware in order: CORS -> RequestID -> Timeout -> Logging -> routes + +## Dependencies +- No `github.com/google/uuid` in go.mod - will use `crypto/rand` to generate UUID v4 (avoids adding dependency) +- No existing context key patterns in codebase - introduce from scratch + +## Design Decisions +- Use `crypto/rand` for UUID v4 generation (no new dependency needed) +- Unexported `contextKey` type to prevent collisions +- `TimeoutMiddleware(duration)` returns `func(http.Handler) http.Handler` (parameterized pattern, matching `LoggingMiddleware`) +- Do NOT use `http.TimeoutHandler` (known issues with response hijacking) +- Default timeout: 60 seconds diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log new file mode 100644 index 0000000..502833b --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log @@ -0,0 +1,606 @@ + github.com/parth/smolterms/backend/cmd/server coverage: 0.0% of statements +=== RUN TestAnalyze_CacheHit +--- PASS: TestAnalyze_CacheHit (0.00s) +=== RUN TestAnalyze_CacheMiss +--- PASS: TestAnalyze_CacheMiss (0.00s) +=== RUN TestAnalyze_NilCache +--- PASS: TestAnalyze_NilCache (0.00s) +=== RUN TestAnalyze_CacheGetError +--- PASS: TestAnalyze_CacheGetError (0.00s) +=== RUN TestAnalyze_CacheSetError +--- PASS: TestAnalyze_CacheSetError (0.00s) +=== RUN TestAnalyze_CacheKey_Correct +--- PASS: TestAnalyze_CacheKey_Correct (0.00s) +=== RUN TestAnalyze_NotPolicy_NoCache +--- PASS: TestAnalyze_NotPolicy_NoCache (0.00s) +=== RUN TestCacheKey_Format +--- PASS: TestCacheKey_Format (0.00s) +=== RUN TestCacheKey_Deterministic +--- PASS: TestCacheKey_Deterministic (0.00s) +=== RUN TestCacheKey_DifferentInputs +--- PASS: TestCacheKey_DifferentInputs (0.00s) +=== RUN TestAnalyze_HappyPath +--- PASS: TestAnalyze_HappyPath (0.00s) +=== RUN TestAnalyze_NotPolicy +--- PASS: TestAnalyze_NotPolicy (0.00s) +=== RUN TestAnalyze_EmptyHTML_NotPolicy +--- PASS: TestAnalyze_EmptyHTML_NotPolicy (0.00s) +=== RUN TestAnalyze_RAGStoreError +--- PASS: TestAnalyze_RAGStoreError (0.00s) +=== RUN TestAnalyze_RAGRetrieveError +--- PASS: TestAnalyze_RAGRetrieveError (0.00s) +=== RUN TestAnalyze_LLMError +--- PASS: TestAnalyze_LLMError (0.00s) +=== RUN TestAnalyze_LLMResponseParseError +--- PASS: TestAnalyze_LLMResponseParseError (0.00s) +=== RUN TestAnalyze_ContextCancelled +--- PASS: TestAnalyze_ContextCancelled (0.00s) +=== RUN TestAnalyze_LogsStages +--- PASS: TestAnalyze_LogsStages (0.00s) +=== RUN TestAnalyze_CorrectDependencyCalls +--- PASS: TestAnalyze_CorrectDependencyCalls (0.00s) +=== RUN TestNewAnalyzer_NilDependencies +=== RUN TestNewAnalyzer_NilDependencies/nil_rag +=== RUN TestNewAnalyzer_NilDependencies/nil_llm +--- PASS: TestNewAnalyzer_NilDependencies (0.00s) + --- PASS: TestNewAnalyzer_NilDependencies/nil_rag (0.00s) + --- PASS: TestNewAnalyzer_NilDependencies/nil_llm (0.00s) +=== RUN TestNewAnalyzer_DefaultLogger +--- PASS: TestNewAnalyzer_DefaultLogger (0.00s) +=== RUN TestContentHash_Deterministic +--- PASS: TestContentHash_Deterministic (0.00s) +=== RUN TestContentHash_Unique +--- PASS: TestContentHash_Unique (0.00s) +=== RUN TestParseLLMResponse_ValidJSON +--- PASS: TestParseLLMResponse_ValidJSON (0.00s) +=== RUN TestParseLLMResponse_JSONCodeBlock +--- PASS: TestParseLLMResponse_JSONCodeBlock (0.00s) +=== RUN TestParseLLMResponse_PlainCodeBlock +--- PASS: TestParseLLMResponse_PlainCodeBlock (0.00s) +=== RUN TestParseLLMResponse_InvalidJSON +=== RUN TestParseLLMResponse_InvalidJSON/malformed +=== RUN TestParseLLMResponse_InvalidJSON/empty +=== RUN TestParseLLMResponse_InvalidJSON/plain_text +--- PASS: TestParseLLMResponse_InvalidJSON (0.00s) + --- PASS: TestParseLLMResponse_InvalidJSON/malformed (0.00s) + --- PASS: TestParseLLMResponse_InvalidJSON/empty (0.00s) + --- PASS: TestParseLLMResponse_InvalidJSON/plain_text (0.00s) +=== RUN TestDetermineRiskLevel +=== RUN TestDetermineRiskLevel/#00 +=== RUN TestDetermineRiskLevel/#01 +=== RUN TestDetermineRiskLevel/#02 +=== RUN TestDetermineRiskLevel/#03 +=== RUN TestDetermineRiskLevel/#04 +=== RUN TestDetermineRiskLevel/#05 +=== RUN TestDetermineRiskLevel/#06 +=== RUN TestDetermineRiskLevel/#07 +--- PASS: TestDetermineRiskLevel (0.00s) + --- PASS: TestDetermineRiskLevel/#00 (0.00s) + --- PASS: TestDetermineRiskLevel/#01 (0.00s) + --- PASS: TestDetermineRiskLevel/#02 (0.00s) + --- PASS: TestDetermineRiskLevel/#03 (0.00s) + --- PASS: TestDetermineRiskLevel/#04 (0.00s) + --- PASS: TestDetermineRiskLevel/#05 (0.00s) + --- PASS: TestDetermineRiskLevel/#06 (0.00s) + --- PASS: TestDetermineRiskLevel/#07 (0.00s) +=== RUN TestAggregate_StandardScores +--- PASS: TestAggregate_StandardScores (0.00s) +=== RUN TestAggregate_ClampsHighScores +--- PASS: TestAggregate_ClampsHighScores (0.00s) +=== RUN TestAggregate_ClampsLowScores +--- PASS: TestAggregate_ClampsLowScores (0.00s) +=== RUN TestAggregate_ZeroValueDefaultsToOne +--- PASS: TestAggregate_ZeroValueDefaultsToOne (0.00s) +=== RUN TestAggregate_RoundingToOneDecimal +--- PASS: TestAggregate_RoundingToOneDecimal (0.00s) +=== RUN TestAggregate_NilKeyConcerns +--- PASS: TestAggregate_NilKeyConcerns (0.00s) +=== RUN TestAnalysisResult_JSONSerialization +--- PASS: TestAnalysisResult_JSONSerialization (0.00s) +=== RUN TestAnalysisRequest_JSONDeserialization +--- PASS: TestAnalysisRequest_JSONDeserialization (0.00s) +=== RUN TestRiskLevelConstants +--- PASS: TestRiskLevelConstants (0.00s) +=== RUN TestDimensionConstants +--- PASS: TestDimensionConstants (0.00s) +=== RUN TestAllDimensions_StableOrder +--- PASS: TestAllDimensions_StableOrder (0.00s) +=== RUN TestDimScore_JSONRoundTrip +--- PASS: TestDimScore_JSONRoundTrip (0.00s) +PASS +coverage: 93.3% of statements +ok github.com/parth/smolterms/backend/internal/analyzer 0.004s coverage: 93.3% of statements +=== RUN TestHealthHandler +=== RUN TestHealthHandler/returns_200_status +=== RUN TestHealthHandler/returns_status_ok_JSON_body +=== RUN TestHealthHandler/sets_content-type_to_application/json +=== RUN TestHealthHandler/reports_configured_services +=== RUN TestHealthHandler/reports_not_configured_when_API_keys_are_empty +--- PASS: TestHealthHandler (0.00s) + --- PASS: TestHealthHandler/returns_200_status (0.00s) + --- PASS: TestHealthHandler/returns_status_ok_JSON_body (0.00s) + --- PASS: TestHealthHandler/sets_content-type_to_application/json (0.00s) + --- PASS: TestHealthHandler/reports_configured_services (0.00s) + --- PASS: TestHealthHandler/reports_not_configured_when_API_keys_are_empty (0.00s) +=== RUN TestCORSMiddleware +=== RUN TestCORSMiddleware/sets_CORS_headers_on_regular_request +=== RUN TestCORSMiddleware/handles_OPTIONS_with_204_and_no_body +=== RUN TestCORSMiddleware/passes_through_to_next_handler_for_non-OPTIONS +=== RUN TestCORSMiddleware/does_not_call_next_handler_for_OPTIONS +--- PASS: TestCORSMiddleware (0.00s) + --- PASS: TestCORSMiddleware/sets_CORS_headers_on_regular_request (0.00s) + --- PASS: TestCORSMiddleware/handles_OPTIONS_with_204_and_no_body (0.00s) + --- PASS: TestCORSMiddleware/passes_through_to_next_handler_for_non-OPTIONS (0.00s) + --- PASS: TestCORSMiddleware/does_not_call_next_handler_for_OPTIONS (0.00s) +=== RUN TestLoggingMiddleware +=== RUN TestLoggingMiddleware/logs_method,_path,_status,_and_duration +=== RUN TestLoggingMiddleware/captures_correct_status_code_from_handler +=== RUN TestLoggingMiddleware/defaults_to_200_when_handler_does_not_call_WriteHeader +=== RUN TestLoggingMiddleware/includes_request_id_when_present_in_context +=== RUN TestLoggingMiddleware/omits_request_id_when_not_in_context +--- PASS: TestLoggingMiddleware (0.00s) + --- PASS: TestLoggingMiddleware/logs_method,_path,_status,_and_duration (0.00s) + --- PASS: TestLoggingMiddleware/captures_correct_status_code_from_handler (0.00s) + --- PASS: TestLoggingMiddleware/defaults_to_200_when_handler_does_not_call_WriteHeader (0.00s) + --- PASS: TestLoggingMiddleware/includes_request_id_when_present_in_context (0.00s) + --- PASS: TestLoggingMiddleware/omits_request_id_when_not_in_context (0.00s) +=== RUN TestRequestIDMiddleware +=== RUN TestRequestIDMiddleware/generates_UUID_when_no_X-Request-ID_header_present +=== RUN TestRequestIDMiddleware/uses_existing_X-Request-ID_header_value +=== RUN TestRequestIDMiddleware/injects_request_ID_into_context +=== RUN TestRequestIDMiddleware/calls_next_handler +--- PASS: TestRequestIDMiddleware (0.00s) + --- PASS: TestRequestIDMiddleware/generates_UUID_when_no_X-Request-ID_header_present (0.00s) + --- PASS: TestRequestIDMiddleware/uses_existing_X-Request-ID_header_value (0.00s) + --- PASS: TestRequestIDMiddleware/injects_request_ID_into_context (0.00s) + --- PASS: TestRequestIDMiddleware/calls_next_handler (0.00s) +=== RUN TestRequestIDFromContext +=== RUN TestRequestIDFromContext/returns_ID_when_present_in_context +=== RUN TestRequestIDFromContext/returns_empty_string_when_not_in_context +--- PASS: TestRequestIDFromContext (0.00s) + --- PASS: TestRequestIDFromContext/returns_ID_when_present_in_context (0.00s) + --- PASS: TestRequestIDFromContext/returns_empty_string_when_not_in_context (0.00s) +=== RUN TestTimeoutMiddleware +=== RUN TestTimeoutMiddleware/sets_context_deadline +=== RUN TestTimeoutMiddleware/context_cancelled_after_deadline_exceeded +=== RUN TestTimeoutMiddleware/calls_next_handler +--- PASS: TestTimeoutMiddleware (0.01s) + --- PASS: TestTimeoutMiddleware/sets_context_deadline (0.00s) + --- PASS: TestTimeoutMiddleware/context_cancelled_after_deadline_exceeded (0.01s) + --- PASS: TestTimeoutMiddleware/calls_next_handler (0.00s) +=== RUN TestWriteJSON +=== RUN TestWriteJSON/sets_content_type_to_application/json +=== RUN TestWriteJSON/sets_correct_status_code +=== RUN TestWriteJSON/encodes_struct_as_JSON_body +=== RUN TestWriteJSON/encodes_nil_as_null +=== RUN TestWriteJSON/returns_500_on_encoding_error +2026/02/25 23:53:53 ERROR failed to marshal JSON response error="json: unsupported type: chan int" +--- PASS: TestWriteJSON (0.00s) + --- PASS: TestWriteJSON/sets_content_type_to_application/json (0.00s) + --- PASS: TestWriteJSON/sets_correct_status_code (0.00s) + --- PASS: TestWriteJSON/encodes_struct_as_JSON_body (0.00s) + --- PASS: TestWriteJSON/encodes_nil_as_null (0.00s) + --- PASS: TestWriteJSON/returns_500_on_encoding_error (0.00s) +=== RUN TestWriteError +=== RUN TestWriteError/sets_content_type_to_application/json +=== RUN TestWriteError/sets_correct_status_code +=== RUN TestWriteError/body_has_error_field_with_message +=== RUN TestWriteError/works_with_500_status +--- PASS: TestWriteError (0.00s) + --- PASS: TestWriteError/sets_content_type_to_application/json (0.00s) + --- PASS: TestWriteError/sets_correct_status_code (0.00s) + --- PASS: TestWriteError/body_has_error_field_with_message (0.00s) + --- PASS: TestWriteError/works_with_500_status (0.00s) +=== RUN TestNewRouter +=== RUN TestNewRouter/health_endpoint_returns_200_with_status_ok +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=21.027µs request_id=4a409adc-b846-47b5-a524-bbb1ef293225 +=== RUN TestNewRouter/unknown_route_returns_404_with_JSON_error +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/nonexistent status=404 duration=5.416µs request_id=aff77663-9e7b-42b3-8777-a1d810aacb68 +=== RUN TestNewRouter/CORS_headers_present_on_health_endpoint +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=5.066µs request_id=498e8f04-3504-4cc1-803e-b779e1aca88e +=== RUN TestNewRouter/OPTIONS_returns_204 +=== RUN TestNewRouter/X-Request-ID_header_present_on_health_endpoint +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=13.11µs request_id=b0bf632a-121d-4731-aae5-4042eade7e60 +=== RUN TestNewRouter/X-Request-ID_passthrough_from_client_header +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=14.91µs request_id=client-trace-id +--- PASS: TestNewRouter (0.00s) + --- PASS: TestNewRouter/health_endpoint_returns_200_with_status_ok (0.00s) + --- PASS: TestNewRouter/unknown_route_returns_404_with_JSON_error (0.00s) + --- PASS: TestNewRouter/CORS_headers_present_on_health_endpoint (0.00s) + --- PASS: TestNewRouter/OPTIONS_returns_204 (0.00s) + --- PASS: TestNewRouter/X-Request-ID_header_present_on_health_endpoint (0.00s) + --- PASS: TestNewRouter/X-Request-ID_passthrough_from_client_header (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/api 0.014s coverage: 100.0% of statements +=== RUN TestCacheKey_Deterministic +--- PASS: TestCacheKey_Deterministic (0.00s) +=== RUN TestCacheKey_DifferentURLs +--- PASS: TestCacheKey_DifferentURLs (0.00s) +=== RUN TestCacheKey_DifferentHashes +--- PASS: TestCacheKey_DifferentHashes (0.00s) +=== RUN TestCacheKey_Format +--- PASS: TestCacheKey_Format (0.00s) +=== RUN TestMockCache_GetReturnsConfiguredResult +--- PASS: TestMockCache_GetReturnsConfiguredResult (0.00s) +=== RUN TestMockCache_GetReturnsConfiguredError +--- PASS: TestMockCache_GetReturnsConfiguredError (0.00s) +=== RUN TestMockCache_SetRecordsCalls +--- PASS: TestMockCache_SetRecordsCalls (0.00s) +=== RUN TestMockCache_SetReturnsConfiguredError +--- PASS: TestMockCache_SetReturnsConfiguredError (0.00s) +=== RUN TestMockCache_GetRecordsCalls +--- PASS: TestMockCache_GetRecordsCalls (0.00s) +=== RUN TestMemoryCache_GetMissReturnsNil +--- PASS: TestMemoryCache_GetMissReturnsNil (0.00s) +=== RUN TestMemoryCache_SetAndGet +--- PASS: TestMemoryCache_SetAndGet (0.00s) +=== RUN TestMemoryCache_DifferentKeys +--- PASS: TestMemoryCache_DifferentKeys (0.00s) +=== RUN TestMemoryCache_Expiration +--- PASS: TestMemoryCache_Expiration (0.03s) +=== RUN TestMemoryCache_TypeSafety +--- PASS: TestMemoryCache_TypeSafety (0.00s) +=== RUN TestMemoryCache_OverwriteKey +--- PASS: TestMemoryCache_OverwriteKey (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/cache 0.035s coverage: 100.0% of statements +=== RUN TestLoad +=== RUN TestLoad/loads_all_fields_from_environment +=== RUN TestLoad/applies_defaults_for_optional_fields_when_unset +=== RUN TestLoad/returns_error_when_ANTHROPIC_API_KEY_is_missing +=== RUN TestLoad/returns_error_when_OPENAI_API_KEY_is_missing +=== RUN TestLoad/lists_all_missing_required_fields_in_error +--- PASS: TestLoad (0.00s) + --- PASS: TestLoad/loads_all_fields_from_environment (0.00s) + --- PASS: TestLoad/applies_defaults_for_optional_fields_when_unset (0.00s) + --- PASS: TestLoad/returns_error_when_ANTHROPIC_API_KEY_is_missing (0.00s) + --- PASS: TestLoad/returns_error_when_OPENAI_API_KEY_is_missing (0.00s) + --- PASS: TestLoad/lists_all_missing_required_fields_in_error (0.00s) +=== RUN TestParseLogLevel +=== RUN TestParseLogLevel/debug +=== RUN TestParseLogLevel/info +=== RUN TestParseLogLevel/warn +=== RUN TestParseLogLevel/error +=== RUN TestParseLogLevel/#00 +=== RUN TestParseLogLevel/verbose +=== RUN TestParseLogLevel/TRACE +--- PASS: TestParseLogLevel (0.00s) + --- PASS: TestParseLogLevel/debug (0.00s) + --- PASS: TestParseLogLevel/info (0.00s) + --- PASS: TestParseLogLevel/warn (0.00s) + --- PASS: TestParseLogLevel/error (0.00s) + --- PASS: TestParseLogLevel/#00 (0.00s) + --- PASS: TestParseLogLevel/verbose (0.00s) + --- PASS: TestParseLogLevel/TRACE (0.00s) +=== RUN TestNewLogger +=== RUN TestNewLogger/produces_valid_JSON_output +=== RUN TestNewLogger/filters_messages_below_configured_level +=== RUN TestNewLogger/NewLogger_writes_to_stderr_and_does_not_panic +--- PASS: TestNewLogger (0.00s) + --- PASS: TestNewLogger/produces_valid_JSON_output (0.00s) + --- PASS: TestNewLogger/filters_messages_below_configured_level (0.00s) + --- PASS: TestNewLogger/NewLogger_writes_to_stderr_and_does_not_panic (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/config 0.002s coverage: 100.0% of statements +=== RUN TestMockEmbeddingClient_ReturnsConfiguredVectors +--- PASS: TestMockEmbeddingClient_ReturnsConfiguredVectors (0.00s) +=== RUN TestMockEmbeddingClient_ReturnsConfiguredError +--- PASS: TestMockEmbeddingClient_ReturnsConfiguredError (0.00s) +=== RUN TestMockEmbeddingClient_RecordsSingleCall +--- PASS: TestMockEmbeddingClient_RecordsSingleCall (0.00s) +=== RUN TestMockEmbeddingClient_RecordsMultipleCalls +--- PASS: TestMockEmbeddingClient_RecordsMultipleCalls (0.00s) +=== RUN TestMockEmbeddingClient_ZeroValueReturnsNil +--- PASS: TestMockEmbeddingClient_ZeroValueReturnsNil (0.00s) +=== RUN TestOpenAIClient_EmptyInputReturnsEmptySlice +--- PASS: TestOpenAIClient_EmptyInputReturnsEmptySlice (0.00s) +=== RUN TestOpenAIClient_SingleTextReturnsSingleVector +--- PASS: TestOpenAIClient_SingleTextReturnsSingleVector (0.00s) +=== RUN TestOpenAIClient_BatchEmbeddingReturnsSameOrder +--- PASS: TestOpenAIClient_BatchEmbeddingReturnsSameOrder (0.00s) +=== RUN TestOpenAIClient_ContextCancellationReturnsError +--- PASS: TestOpenAIClient_ContextCancellationReturnsError (0.00s) +=== RUN TestOpenAIClient_APIErrorReturnsWrappedError +--- PASS: TestOpenAIClient_APIErrorReturnsWrappedError (0.00s) +=== RUN TestOpenAIClient_UnauthorizedReturnsWrappedError +--- PASS: TestOpenAIClient_UnauthorizedReturnsWrappedError (0.00s) +=== RUN TestOpenAIClient_Returns1536DimensionVector +--- PASS: TestOpenAIClient_Returns1536DimensionVector (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/embedding 0.006s coverage: 100.0% of statements +=== RUN TestEstimateTokens +=== RUN TestEstimateTokens/empty_string +=== RUN TestEstimateTokens/single_word +=== RUN TestEstimateTokens/three_words +=== RUN TestEstimateTokens/ten_words +=== RUN TestEstimateTokens/whitespace_only +--- PASS: TestEstimateTokens (0.00s) + --- PASS: TestEstimateTokens/empty_string (0.00s) + --- PASS: TestEstimateTokens/single_word (0.00s) + --- PASS: TestEstimateTokens/three_words (0.00s) + --- PASS: TestEstimateTokens/ten_words (0.00s) + --- PASS: TestEstimateTokens/whitespace_only (0.00s) +=== RUN TestEstimateTokens_Approximation +--- PASS: TestEstimateTokens_Approximation (0.00s) +=== RUN TestChunkContent_EmptyInput +--- PASS: TestChunkContent_EmptyInput (0.00s) +=== RUN TestChunkContent_DefaultOptions +--- PASS: TestChunkContent_DefaultOptions (0.00s) +=== RUN TestChunkContent_SingleChunkShortDocument +--- PASS: TestChunkContent_SingleChunkShortDocument (0.00s) +=== RUN TestChunkContent_SectionsWithinTarget +--- PASS: TestChunkContent_SectionsWithinTarget (0.00s) +=== RUN TestChunkContent_LargeSectionSplitAtParagraphs +--- PASS: TestChunkContent_LargeSectionSplitAtParagraphs (0.00s) +=== RUN TestChunkContent_NoOverlapAcrossSections +--- PASS: TestChunkContent_NoOverlapAcrossSections (0.00s) +=== RUN TestChunkContent_OverlapWithinSection +--- PASS: TestChunkContent_OverlapWithinSection (0.00s) +=== RUN TestChunkContent_SentenceSplitting +--- PASS: TestChunkContent_SentenceSplitting (0.00s) +=== RUN TestChunkContent_UnstructuredFallback +--- PASS: TestChunkContent_UnstructuredFallback (0.00s) +=== RUN TestChunkContent_UnstructuredShortText +--- PASS: TestChunkContent_UnstructuredShortText (0.00s) +=== RUN TestChunkContent_SequentialIndexes +--- PASS: TestChunkContent_SequentialIndexes (0.00s) +=== RUN TestChunkContent_SectionHeadingOnly +--- PASS: TestChunkContent_SectionHeadingOnly (0.00s) +=== RUN TestIsPrivacyPolicy_RealPolicyPage +--- PASS: TestIsPrivacyPolicy_RealPolicyPage (0.00s) +=== RUN TestIsPrivacyPolicy_NonPolicyPage +--- PASS: TestIsPrivacyPolicy_NonPolicyPage (0.00s) +=== RUN TestIsPrivacyPolicy_HeadingSignalDominates +--- PASS: TestIsPrivacyPolicy_HeadingSignalDominates (0.00s) +=== RUN TestIsPrivacyPolicy_KeywordDensityWithoutHeadings +--- PASS: TestIsPrivacyPolicy_KeywordDensityWithoutHeadings (0.00s) +=== RUN TestIsPrivacyPolicy_CaseInsensitivity +--- PASS: TestIsPrivacyPolicy_CaseInsensitivity (0.00s) +=== RUN TestIsPrivacyPolicy_EmptyContent +--- PASS: TestIsPrivacyPolicy_EmptyContent (0.00s) +=== RUN TestIsPrivacyPolicy_NilContent +--- PASS: TestIsPrivacyPolicy_NilContent (0.00s) +=== RUN TestIsPrivacyPolicy_ShortContent +--- PASS: TestIsPrivacyPolicy_ShortContent (0.00s) +=== RUN TestIsPrivacyPolicy_TermsOfService +--- PASS: TestIsPrivacyPolicy_TermsOfService (0.00s) +=== RUN TestIsPrivacyPolicy_TermsAndConditions +--- PASS: TestIsPrivacyPolicy_TermsAndConditions (0.00s) +=== RUN TestIsPrivacyPolicy_ConfidenceInRange +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/empty +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/short +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/real_policy +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/news_article +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/high_density_no_heading +--- PASS: TestIsPrivacyPolicy_ConfidenceInRange (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/empty (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/short (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/real_policy (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/news_article (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/high_density_no_heading (0.00s) +=== RUN TestIsPrivacyPolicy_MultipleHeadings +--- PASS: TestIsPrivacyPolicy_MultipleHeadings (0.00s) +=== RUN TestDefaultParseOptions +--- PASS: TestDefaultParseOptions (0.00s) +=== RUN TestParse_CleanTextExtraction +--- PASS: TestParse_CleanTextExtraction (0.00s) +=== RUN TestParse_StripsUnwantedElements +--- PASS: TestParse_StripsUnwantedElements (0.00s) +=== RUN TestParse_StripsHiddenContent +=== RUN TestParse_StripsHiddenContent/display:none +=== RUN TestParse_StripsHiddenContent/display:none_no_space +=== RUN TestParse_StripsHiddenContent/visibility:hidden +=== RUN TestParse_StripsHiddenContent/opacity:0 +=== RUN TestParse_StripsHiddenContent/opacity:0_no_space +=== RUN TestParse_StripsHiddenContent/font-size:0 +=== RUN TestParse_StripsHiddenContent/font-size:0_no_space +=== RUN TestParse_StripsHiddenContent/hidden_attribute +=== RUN TestParse_StripsHiddenContent/aria-hidden=true +=== RUN TestParse_StripsHiddenContent/off-screen_positioned +=== RUN TestParse_StripsHiddenContent/off-screen_top +--- PASS: TestParse_StripsHiddenContent (0.00s) + --- PASS: TestParse_StripsHiddenContent/display:none (0.00s) + --- PASS: TestParse_StripsHiddenContent/display:none_no_space (0.00s) + --- PASS: TestParse_StripsHiddenContent/visibility:hidden (0.00s) + --- PASS: TestParse_StripsHiddenContent/opacity:0 (0.00s) + --- PASS: TestParse_StripsHiddenContent/opacity:0_no_space (0.00s) + --- PASS: TestParse_StripsHiddenContent/font-size:0 (0.00s) + --- PASS: TestParse_StripsHiddenContent/font-size:0_no_space (0.00s) + --- PASS: TestParse_StripsHiddenContent/hidden_attribute (0.00s) + --- PASS: TestParse_StripsHiddenContent/aria-hidden=true (0.00s) + --- PASS: TestParse_StripsHiddenContent/off-screen_positioned (0.00s) + --- PASS: TestParse_StripsHiddenContent/off-screen_top (0.00s) +=== RUN TestParse_PreservesHiddenContent +--- PASS: TestParse_PreservesHiddenContent (0.00s) +=== RUN TestParse_SectionHeadings +--- PASS: TestParse_SectionHeadings (0.00s) +=== RUN TestParse_HeadingHierarchy +--- PASS: TestParse_HeadingHierarchy (0.00s) +=== RUN TestParse_Breadcrumb +--- PASS: TestParse_Breadcrumb (0.00s) +=== RUN TestParse_CharacterOffsets +--- PASS: TestParse_CharacterOffsets (0.00s) +=== RUN TestParse_SemanticContainers +--- PASS: TestParse_SemanticContainers (0.00s) +=== RUN TestParse_UnstructuredHTML +--- PASS: TestParse_UnstructuredHTML (0.00s) +=== RUN TestParse_EmptyAndMalformed +=== RUN TestParse_EmptyAndMalformed/empty_string +=== RUN TestParse_EmptyAndMalformed/whitespace_only +=== RUN TestParse_EmptyAndMalformed/malformed_HTML +=== RUN TestParse_EmptyAndMalformed/plain_text +--- PASS: TestParse_EmptyAndMalformed (0.00s) + --- PASS: TestParse_EmptyAndMalformed/empty_string (0.00s) + --- PASS: TestParse_EmptyAndMalformed/whitespace_only (0.00s) + --- PASS: TestParse_EmptyAndMalformed/malformed_HTML (0.00s) + --- PASS: TestParse_EmptyAndMalformed/plain_text (0.00s) +=== RUN TestParse_PlainTextContent +--- PASS: TestParse_PlainTextContent (0.00s) +=== RUN TestParse_AccurateCounts +--- PASS: TestParse_AccurateCounts (0.00s) +=== RUN TestParse_HTMLComments +--- PASS: TestParse_HTMLComments (0.00s) +PASS +coverage: 94.4% of statements +ok github.com/parth/smolterms/backend/internal/extractor 0.003s coverage: 94.4% of statements +=== RUN TestAnthropicClient_SuccessfulResponse +--- PASS: TestAnthropicClient_SuccessfulResponse (0.00s) +=== RUN TestAnthropicClient_RequestFormat +--- PASS: TestAnthropicClient_RequestFormat (0.00s) +=== RUN TestAnthropicClient_RateLimitError +--- PASS: TestAnthropicClient_RateLimitError (0.00s) +=== RUN TestAnthropicClient_APIError +--- PASS: TestAnthropicClient_APIError (0.00s) +=== RUN TestAnthropicClient_ContextCancellation +--- PASS: TestAnthropicClient_ContextCancellation (0.00s) +=== RUN TestAnthropicClient_EmptyResponseReturnsError +--- PASS: TestAnthropicClient_EmptyResponseReturnsError (0.00s) +=== RUN TestAnthropicClient_LogsCallDetails +--- PASS: TestAnthropicClient_LogsCallDetails (0.00s) +=== RUN TestMockLLMClient_ReturnsConfiguredResponse +--- PASS: TestMockLLMClient_ReturnsConfiguredResponse (0.00s) +=== RUN TestMockLLMClient_ReturnsConfiguredError +--- PASS: TestMockLLMClient_ReturnsConfiguredError (0.00s) +=== RUN TestMockLLMClient_RecordsCalls +--- PASS: TestMockLLMClient_RecordsCalls (0.00s) +=== RUN TestMockLLMClient_MultipleCalls +--- PASS: TestMockLLMClient_MultipleCalls (0.00s) +=== RUN TestSystemPrompt_Content +--- PASS: TestSystemPrompt_Content (0.00s) +=== RUN TestBuildAnalysisPrompt_ContainsURL +--- PASS: TestBuildAnalysisPrompt_ContainsURL (0.00s) +=== RUN TestBuildAnalysisPrompt_FormatsChunks +--- PASS: TestBuildAnalysisPrompt_FormatsChunks (0.00s) +=== RUN TestBuildAnalysisPrompt_IncludesJSONStructure +--- PASS: TestBuildAnalysisPrompt_IncludesJSONStructure (0.00s) +=== RUN TestBuildAnalysisPrompt_IncludesScoringGuide +--- PASS: TestBuildAnalysisPrompt_IncludesScoringGuide (0.00s) +=== RUN TestBuildAnalysisPrompt_EmptyChunks +--- PASS: TestBuildAnalysisPrompt_EmptyChunks (0.00s) +=== RUN TestBuildAnalysisPrompt_ChunkIndexIncluded +--- PASS: TestBuildAnalysisPrompt_ChunkIndexIncluded (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/llm 0.007s coverage: 100.0% of statements +=== RUN TestStore_EmbedsAndUpsertsCorrectly +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=3 embed_latency=459ns upsert_latency=276ns +--- PASS: TestStore_EmbedsAndUpsertsCorrectly (0.00s) +=== RUN TestStore_ConvertChunksMetadata +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=2 embed_latency=213ns upsert_latency=232ns +--- PASS: TestStore_ConvertChunksMetadata (0.00s) +=== RUN TestStore_AssignsVectors +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=2 embed_latency=111ns upsert_latency=209ns +--- PASS: TestStore_AssignsVectors (0.00s) +=== RUN TestStore_EmbedError +--- PASS: TestStore_EmbedError (0.00s) +=== RUN TestStore_UpsertError +--- PASS: TestStore_UpsertError (0.00s) +=== RUN TestRetrieve_EmbedsAndSearches +2026/02/25 23:53:53 INFO retrieved chunks operation=retrieve query="privacy data collection" result_count=1 embed_latency=156ns search_latency=238ns +--- PASS: TestRetrieve_EmbedsAndSearches (0.00s) +=== RUN TestRetrieve_DeduplicatesByText +2026/02/25 23:53:53 INFO retrieved chunks operation=retrieve query=query result_count=2 embed_latency=143ns search_latency=74ns +--- PASS: TestRetrieve_DeduplicatesByText (0.00s) +=== RUN TestRetrieve_EmbedError +--- PASS: TestRetrieve_EmbedError (0.00s) +=== RUN TestRetrieve_SearchError +--- PASS: TestRetrieve_SearchError (0.00s) +=== RUN TestStore_ContextCancellation +--- PASS: TestStore_ContextCancellation (0.00s) +=== RUN TestRetrieve_ContextCancellation +--- PASS: TestRetrieve_ContextCancellation (0.00s) +=== RUN TestStore_LogsOperationFields +--- PASS: TestStore_LogsOperationFields (0.00s) +=== RUN TestRetrieve_LogsOperationFields +--- PASS: TestRetrieve_LogsOperationFields (0.00s) +=== RUN TestNewPipeline_NilLoggerDefaultsToSlogDefault +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=1 embed_latency=119ns upsert_latency=40ns +--- PASS: TestNewPipeline_NilLoggerDefaultsToSlogDefault (0.00s) +PASS +coverage: 95.9% of statements +ok github.com/parth/smolterms/backend/internal/rag 0.002s coverage: 95.9% of statements +? github.com/parth/smolterms/backend/internal/types [no test files] +=== RUN TestNewQdrantStore_InvalidURL +--- PASS: TestNewQdrantStore_InvalidURL (0.00s) +=== RUN TestQdrantStore_Upsert_Empty +--- PASS: TestQdrantStore_Upsert_Empty (0.00s) +=== RUN TestQdrantStore_Upsert_FormatsPointsCorrectly +2026/02/25 23:53:53 INFO upserted chunks collection=policies count=1 +--- PASS: TestQdrantStore_Upsert_FormatsPointsCorrectly (0.00s) +=== RUN TestQdrantStore_Upsert_ReturnsError +--- PASS: TestQdrantStore_Upsert_ReturnsError (0.00s) +=== RUN TestQdrantStore_Search_ReturnsOrderedResults +2026/02/25 23:53:53 INFO search complete collection=col1 results=2 latency_ms=0 +--- PASS: TestQdrantStore_Search_ReturnsOrderedResults (0.00s) +=== RUN TestQdrantStore_Search_ReconstructsChunks +2026/02/25 23:53:53 INFO search complete collection=col1 results=1 latency_ms=0 +--- PASS: TestQdrantStore_Search_ReconstructsChunks (0.00s) +=== RUN TestQdrantStore_Search_ReturnsError +--- PASS: TestQdrantStore_Search_ReturnsError (0.00s) +=== RUN TestQdrantStore_EnsureCollection_CreatesIfNotExists +2026/02/25 23:53:53 INFO creating qdrant collection collection=new-col +2026/02/25 23:53:53 INFO upserted chunks collection=new-col count=1 +--- PASS: TestQdrantStore_EnsureCollection_CreatesIfNotExists (0.00s) +=== RUN TestQdrantStore_EnsureCollection_SkipsIfExists +2026/02/25 23:53:53 INFO upserted chunks collection=existing-col count=1 +--- PASS: TestQdrantStore_EnsureCollection_SkipsIfExists (0.00s) +=== RUN TestQdrantStore_EnsureCollection_CachesResult +2026/02/25 23:53:53 INFO upserted chunks collection=col1 count=1 +2026/02/25 23:53:53 INFO upserted chunks collection=col1 count=1 +--- PASS: TestQdrantStore_EnsureCollection_CachesResult (0.00s) +=== RUN TestQdrantStore_ConnectionFailure +--- PASS: TestQdrantStore_ConnectionFailure (0.00s) +=== RUN TestQdrantStore_ContextCancellation_Upsert +--- PASS: TestQdrantStore_ContextCancellation_Upsert (0.00s) +=== RUN TestQdrantStore_ContextCancellation_Search +--- PASS: TestQdrantStore_ContextCancellation_Search (0.00s) +=== RUN TestQdrantStore_Search_QueryParams +2026/02/25 23:53:53 INFO search complete collection=test-col results=0 latency_ms=0 +--- PASS: TestQdrantStore_Search_QueryParams (0.00s) +=== RUN TestChunkUUID_Deterministic +--- PASS: TestChunkUUID_Deterministic (0.00s) +=== RUN TestNewQdrantStore_ValidURL +--- PASS: TestNewQdrantStore_ValidURL (0.00s) +=== RUN TestQdrantStore_EnsureCollection_CreateCollectionError +2026/02/25 23:53:53 INFO creating qdrant collection collection=col1 +--- PASS: TestQdrantStore_EnsureCollection_CreateCollectionError (0.00s) +=== RUN TestQdrantStore_EnsureCollection_FieldIndexError +2026/02/25 23:53:53 INFO creating qdrant collection collection=col1 +--- PASS: TestQdrantStore_EnsureCollection_FieldIndexError (0.00s) +=== RUN TestQdrantStore_Upsert_PrefersChunkID +2026/02/25 23:53:53 INFO upserted chunks collection=col count=2 +--- PASS: TestQdrantStore_Upsert_PrefersChunkID (0.00s) +=== RUN TestQdrantStore_Search_PopulatesChunkID +2026/02/25 23:53:53 INFO search complete collection=col1 results=1 latency_ms=0 +--- PASS: TestQdrantStore_Search_PopulatesChunkID (0.00s) +=== RUN TestQdrantStore_Upsert_LogsEntry +--- PASS: TestQdrantStore_Upsert_LogsEntry (0.00s) +=== RUN TestQdrantStore_Search_LogsEntry +--- PASS: TestQdrantStore_Search_LogsEntry (0.00s) +=== RUN TestChunkFields +--- PASS: TestChunkFields (0.00s) +=== RUN TestVectorStoreInterfaceSatisfied +--- PASS: TestVectorStoreInterfaceSatisfied (0.00s) +=== RUN TestMockUpsert_ReturnsNilByDefault +--- PASS: TestMockUpsert_ReturnsNilByDefault (0.00s) +=== RUN TestMockUpsert_ReturnsConfiguredError +--- PASS: TestMockUpsert_ReturnsConfiguredError (0.00s) +=== RUN TestMockUpsert_RecordsCalls +--- PASS: TestMockUpsert_RecordsCalls (0.00s) +=== RUN TestMockSearch_ReturnsConfiguredChunks +--- PASS: TestMockSearch_ReturnsConfiguredChunks (0.00s) +=== RUN TestMockSearch_ReturnsNilAndEmptyByDefault +--- PASS: TestMockSearch_ReturnsNilAndEmptyByDefault (0.00s) +=== RUN TestMockSearch_ReturnsConfiguredError +--- PASS: TestMockSearch_ReturnsConfiguredError (0.00s) +=== RUN TestMockSearch_RecordsCalls +--- PASS: TestMockSearch_RecordsCalls (0.00s) +=== RUN TestMockRecordsMultipleCalls +--- PASS: TestMockRecordsMultipleCalls (0.00s) +PASS +coverage: 97.6% of statements +ok github.com/parth/smolterms/backend/internal/vectorstore 0.003s coverage: 97.6% of statements diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md new file mode 100644 index 0000000..70937c6 --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md @@ -0,0 +1,63 @@ +# Plan: Request ID and Timeout Middleware + +## Test Strategy + +### Request ID Middleware Tests +1. **Generates UUID when no header present** - Request without X-Request-ID -> response has X-Request-ID header with valid UUID format +2. **Uses existing header value** - Request with X-Request-ID: "test-id-123" -> response X-Request-ID = "test-id-123" +3. **Injects request ID into context** - Downstream handler calls `RequestIDFromContext(r.Context())` -> returns the ID +4. **Generated ID is valid UUID v4 format** - Verify format matches `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` +5. **Calls next handler** - Verify the downstream handler is invoked + +### Timeout Middleware Tests +6. **Sets context deadline** - After middleware, `r.Context().Deadline()` returns true with deadline ~60s from now +7. **Context cancelled after deadline** - With very short timeout (1ms), slow handler sees `context.DeadlineExceeded` +8. **Calls next handler** - Verify the downstream handler is invoked +9. **Cancel function called** - Verify no goroutine leak (cancel is deferred) + +### Logging Middleware Update Tests +10. **Includes request_id in log output** - When request_id is in context, log entry contains `request_id=` +11. **Handles missing request_id** - When no request_id in context, log entry does NOT contain request_id field (or contains empty) + +### RequestIDFromContext Tests +12. **Returns ID when present** - Context with request ID -> returns the ID string +13. **Returns empty string when absent** - Empty context -> returns "" + +### Router Integration Tests +14. **X-Request-ID header present on health** - Health endpoint response includes X-Request-ID +15. **Existing router tests still pass** - All 4 existing TestNewRouter subtests pass + +## Implementation Plan + +### Step 1: Write tests (RED phase) +- Add tests to `middleware_test.go` for RequestIDMiddleware, TimeoutMiddleware, RequestIDFromContext, and updated LoggingMiddleware +- Add integration test to `router_test.go` for X-Request-ID header presence + +### Step 2: Implement middleware (GREEN phase) +1. Add context key type and constant to `middleware.go` +2. Implement `RequestIDMiddleware` in `middleware.go` +3. Implement `RequestIDFromContext` helper in `middleware.go` +4. Implement `TimeoutMiddleware` in `middleware.go` +5. Update `LoggingMiddleware` to include request_id +6. Update `NewRouter` to wire new middleware + +### Step 3: Refactor +- Review for consistency with existing patterns +- Ensure no unnecessary complexity + +## Implementation Checklist +- [ ] Tests written for RequestIDMiddleware +- [ ] Tests written for TimeoutMiddleware +- [ ] Tests written for RequestIDFromContext +- [ ] Tests written for LoggingMiddleware update +- [ ] Tests written for router integration +- [ ] All tests fail (RED confirmed) +- [ ] Context key type and RequestIDFromContext implemented +- [ ] RequestIDMiddleware implemented +- [ ] TimeoutMiddleware implemented +- [ ] LoggingMiddleware updated +- [ ] NewRouter updated +- [ ] All tests pass (GREEN confirmed) +- [ ] Refactoring complete +- [ ] Existing tests still pass +- [ ] Build succeeds diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md new file mode 100644 index 0000000..ea33b0e --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md @@ -0,0 +1,37 @@ +# Progress: Request ID and Timeout Middleware + +## Setup +- [x] Documentation directory created +- [x] Instruction files discovered (none found besides CLAUDE.md) +- [x] Existing code explored and patterns documented + +## Explore +- [x] Design doc section 6 read (error handling, 504 timeout, context propagation) +- [x] Existing middleware.go analyzed (CORSMiddleware, LoggingMiddleware, statusRecorder) +- [x] Existing router.go analyzed (NewRouter with middleware chain) +- [x] Existing tests analyzed (unit + integration patterns) +- [x] No existing context key patterns in codebase + +## Plan +- [x] Test strategy designed +- [x] Implementation plan created + +## Code +- [x] TDD RED phase - tests written and confirmed failing (12 compilation errors) +- [x] TDD GREEN phase - implementation written and all 27 API tests passing +- [x] Refactor phase - code reviewed, consistent with existing patterns +- [x] Validation - all backend tests pass, build succeeds, API package at 100% coverage + +### TDD Cycles +1. RED: Added 13 new test cases across 4 test functions, confirmed compilation failure +2. GREEN: Implemented contextKey, RequestIDMiddleware, RequestIDFromContext, TimeoutMiddleware, updated LoggingMiddleware, updated NewRouter +3. REFACTOR: Reviewed naming, documentation, error handling, code organization - all consistent + +### Design Decisions +- Used `crypto/rand` for UUID v4 generation (no new dependency) +- Unexported `contextKey` type prevents context key collisions +- `generateUUID` is unexported helper (internal implementation detail) +- LoggingMiddleware conditionally includes request_id only when present + +## Commit +- [ ] Changes committed diff --git a/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md new file mode 100644 index 0000000..e428620 --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md @@ -0,0 +1,31 @@ +# Context: Task 02 - Analyze Handler and Routing + +## Project Structure +- Go 1.22+ with stdlib `net/http` method-based routing +- Handler factory pattern: `NewXxxHandler(deps) http.HandlerFunc` +- Response helpers: `WriteJSON[T]` and `WriteError` in `api/response.go` +- Response types in `backend/internal/types/response.go` +- White-box testing with `httptest.NewRecorder` + `httptest.NewRequest` +- Middleware stack: CORS -> RequestID -> Timeout -> Logging -> routes + +## Requirements +1. Create `NewAnalyzeHandler` factory in `handler.go` - accepts `*analyzer.Analyzer` and `*slog.Logger` +2. Decode JSON body into `analyzer.AnalysisRequest`, validate url/html non-empty +3. Error mapping: malformed JSON -> 400, empty fields -> 400, deadline exceeded -> 504, other errors -> 502 +4. Update `NewHealthHandler` to accept optional `func(ctx) error` for Qdrant health check +5. Update `NewRouter` to accept analyzer dependency and register `POST /api/v1/analyze` +6. Update `main.go` to pass new dependencies + +## Existing Patterns +- Factory functions return `http.HandlerFunc` closures capturing dependencies +- Nil-safe: handlers default gracefully on nil deps +- `WriteJSON` / `WriteError` for all responses +- `got/want` naming in test assertions +- Table-driven subtests with `t.Run` +- Handler tests use `httptest.NewRecorder`, router tests use `httptest.NewServer` + +## Dependencies +- `analyzer.Analyzer` has method: `Analyze(ctx, AnalysisRequest) (*AnalysisResult, error)` +- `analyzer.AnalysisRequest` has `URL string` and `HTML string` with json tags +- `analyzer.AnalysisResult` is the full response type (already has json tags matching API contract) +- For testing: need to mock `*analyzer.Analyzer` - cannot construct directly (panics on nil rag/llm) diff --git a/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md new file mode 100644 index 0000000..493a722 --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md @@ -0,0 +1,54 @@ +# Plan: Task 02 - Analyze Handler and Routing + +## Test Strategy + +### Mocking Approach +The `Analyzer` struct uses concrete dependencies (rag.Pipeline, llm.LLMClient) that panic on nil. For handler tests, we need to create an interface that `Analyze` satisfies, or use a thin wrapper. The cleanest approach: define a local `pipelineRunner` interface in the handler with `Analyze(ctx, AnalysisRequest) (*AnalysisResult, error)` that `*analyzer.Analyzer` satisfies. This allows easy mocking in tests. + +### Analyze Handler Tests (handler_test.go) +1. **Valid request returns 200 with AnalysisResult** - POST with valid url+html, mock returns result +2. **Missing URL returns 400** - POST with empty url, expects error message about url +3. **Missing HTML returns 400** - POST with empty html, expects error message about html +4. **Malformed JSON returns 400** - POST with invalid JSON body, expects error about invalid JSON +5. **Empty body returns 400** - POST with no body +6. **Analyzer timeout returns 504** - Mock returns context.DeadlineExceeded, expects 504 +7. **Analyzer error returns 502** - Mock returns generic error, expects 502 +8. **Non-policy result returns 200** - Mock returns result with RiskNotPolicy, expects 200 +9. **Content-Type is application/json** - All responses should have correct content type + +### Health Handler Tests (handler_test.go) +10. **Qdrant connected** - Health check func returns nil, expects "connected" +11. **Qdrant unavailable** - Health check func returns error, expects "unavailable" +12. **Nil health check falls back to URL** - No func provided, shows URL (backward compat) + +### Router Tests (router_test.go) +13. **POST /api/v1/analyze routes to handler** - Integration test with mock analyzer +14. **GET /api/v1/analyze returns 405** - Wrong method +15. **OPTIONS /api/v1/analyze returns 204 with CORS** - Preflight support + +## Implementation Plan + +### Step 1: Define pipeline interface for testability +Add a `PipelineRunner` interface in handler.go: +``` +type PipelineRunner interface { + Analyze(ctx context.Context, req analyzer.AnalysisRequest) (*analyzer.AnalysisResult, error) +} +``` +`*analyzer.Analyzer` implicitly satisfies this. + +### Step 2: Implement NewAnalyzeHandler +Factory: `NewAnalyzeHandler(pipeline PipelineRunner, logger *slog.Logger) http.HandlerFunc` +- Decode JSON, validate, call Analyze, map errors to status codes + +### Step 3: Update NewHealthHandler +Change signature: `NewHealthHandler(cfg *config.Config, qdrantCheck func(ctx context.Context) error) http.HandlerFunc` +- If qdrantCheck != nil: call it, report "connected" or "unavailable" +- If qdrantCheck == nil: fall back to current behavior (show URL) + +### Step 4: Update NewRouter +Change signature: add PipelineRunner parameter +Register `POST /api/v1/analyze` route + +### Step 5: Update main.go +Pass nil for analyzer and qdrant check for now (full wiring deferred to later steps) diff --git a/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md new file mode 100644 index 0000000..1c5798b --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md @@ -0,0 +1,19 @@ +# Progress: Task 02 - Analyze Handler and Routing + +## Setup +- [x] Created documentation directory +- [x] Discovered instruction files (CLAUDE.md) +- [x] Explored existing codebase patterns +- [x] Created context.md + +## Implementation Checklist +- [ ] Design test strategy and plan +- [ ] Write handler tests (RED phase) +- [ ] Implement `NewAnalyzeHandler` in handler.go +- [ ] Update `NewHealthHandler` signature and behavior +- [ ] Update `NewRouter` to accept analyzer and register analyze route +- [ ] Update `main.go` for new router signature +- [ ] Update router tests for analyze endpoint +- [ ] Run all tests and verify GREEN +- [ ] Refactor and validate +- [ ] Commit diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c1d5878 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,5 @@ +## Todo + +[] - Make the request timeout an env config option +[] - Decide how to prevent multiple requests by same user or different users - how to set up limits and prevent DOS +[] - Testing metrics with some privacy policy datasets to evaluate model and chunking/retrieval strategies \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6f5935a..2bd17fb 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,7 +18,11 @@ func main() { logger := config.NewLogger(cfg.LogLevel) - router := api.NewRouter(logger, cfg) + // TODO: Construct analyzer pipeline dependencies (rag.Pipeline, llm.LLMClient, + // embedding.EmbeddingClient, vectorstore.VectorStore, cache) and pass + // the *analyzer.Analyzer as the pipeline parameter. + // TODO: Pass a Qdrant health check function once the vector store is wired. + router := api.NewRouter(logger, cfg, nil, nil) addr := ":" + cfg.Port logger.Info("starting server", "addr", addr) diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 74c246d..16ef51d 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -1,25 +1,103 @@ package api import ( + "context" + "encoding/json" + "errors" + "log/slog" "net/http" + "github.com/parth/smolterms/backend/internal/analyzer" "github.com/parth/smolterms/backend/internal/config" "github.com/parth/smolterms/backend/internal/types" ) +// PipelineRunner abstracts the analysis pipeline for testability. +// *analyzer.Analyzer satisfies this interface. +type PipelineRunner interface { + Analyze(ctx context.Context, req analyzer.AnalysisRequest) (*analyzer.AnalysisResult, error) +} + +// NewAnalyzeHandler returns a handler that accepts HTML content and a URL, +// runs the analysis pipeline, and returns the result as JSON. +// If pipeline is nil, the handler returns 503 Service Unavailable. +// If logger is nil, the default slog logger is used. +func NewAnalyzeHandler(pipeline PipelineRunner, logger *slog.Logger) http.HandlerFunc { + if logger == nil { + logger = slog.Default() + } + return func(w http.ResponseWriter, r *http.Request) { + if pipeline == nil { + WriteError(w, http.StatusServiceUnavailable, "Analysis service not configured") + return + } + + var req analyzer.AnalysisRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, http.StatusBadRequest, "Invalid JSON in request body") + return + } + + if req.URL == "" { + WriteError(w, http.StatusBadRequest, "The url field is required and must be non-empty") + return + } + if req.HTML == "" { + WriteError(w, http.StatusBadRequest, "The html field is required and must be non-empty") + return + } + + requestID := RequestIDFromContext(r.Context()) + + result, err := pipeline.Analyze(r.Context(), req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + logger.Error("analysis timed out", + "url", req.URL, + "request_id", requestID, + "error", err, + ) + WriteError(w, http.StatusGatewayTimeout, "Analysis timed out. Please try again.") + return + } + + logger.Error("analysis failed", + "url", req.URL, + "request_id", requestID, + "error", err, + ) + WriteError(w, http.StatusBadGateway, "Analysis service temporarily unavailable. Please try again.") + return + } + + WriteJSON(w, http.StatusOK, result) + } +} + // NewHealthHandler returns a handler that reports the health status of the server // and its configured services. cfg may be nil (services will show as not_configured). -func NewHealthHandler(cfg *config.Config) http.HandlerFunc { +// qdrantCheck is an optional function that checks Qdrant connectivity. If nil, +// the handler falls back to showing the configured Qdrant URL. +func NewHealthHandler(cfg *config.Config, qdrantCheck func(ctx context.Context) error) http.HandlerFunc { if cfg == nil { cfg = &config.Config{} } return func(w http.ResponseWriter, r *http.Request) { - qdrant := cfg.QdrantURL - if qdrant == "" { - qdrant = "localhost:6334" + qdrantStatus := cfg.QdrantURL + if qdrantStatus == "" { + qdrantStatus = "localhost:6334" } + + if qdrantCheck != nil { + if err := qdrantCheck(r.Context()); err != nil { + qdrantStatus = "unavailable" + } else { + qdrantStatus = "connected" + } + } + services := map[string]string{ - "qdrant": qdrant, + "qdrant": qdrantStatus, "anthropic": apiKeyStatus(cfg.AnthropicAPIKey), "openai": apiKeyStatus(cfg.OpenAIAPIKey), } diff --git a/backend/internal/api/handler_test.go b/backend/internal/api/handler_test.go index 26e7cd9..c1a996c 100644 --- a/backend/internal/api/handler_test.go +++ b/backend/internal/api/handler_test.go @@ -1,11 +1,17 @@ package api import ( + "context" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "time" + "github.com/parth/smolterms/backend/internal/analyzer" "github.com/parth/smolterms/backend/internal/config" ) @@ -20,9 +26,254 @@ func newTestConfig() *config.Config { } } +// mockPipeline implements PipelineRunner for testing. +type mockPipeline struct { + result *analyzer.AnalysisResult + err error +} + +func (m *mockPipeline) Analyze(_ context.Context, _ analyzer.AnalysisRequest) (*analyzer.AnalysisResult, error) { + return m.result, m.err +} + +func TestAnalyzeHandler(t *testing.T) { + successResult := &analyzer.AnalysisResult{ + URL: "https://example.com/privacy", + OverallScore: 7.5, + RiskLevel: analyzer.RiskModerate, + Dimensions: map[string]analyzer.DimScore{ + analyzer.DimDataCollection: {Score: 8.0, Summary: "Good data collection practices"}, + analyzer.DimDataSharing: {Score: 7.0, Summary: "Moderate data sharing"}, + analyzer.DimUserRights: {Score: 7.5, Summary: "Adequate user rights"}, + analyzer.DimRetention: {Score: 7.0, Summary: "Reasonable retention"}, + analyzer.DimSecurity: {Score: 8.0, Summary: "Strong security measures"}, + }, + KeyConcerns: []string{"Third-party analytics"}, + Summary: "Overall moderate privacy practices", + Cached: false, + AnalyzedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + + t.Run("valid request returns 200 with analysis result", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Privacy Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusOK { + t.Errorf("status code = %d, want %d", got, http.StatusOK) + } + + var got analyzer.AnalysisResult + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.URL != successResult.URL { + t.Errorf("url = %q, want %q", got.URL, successResult.URL) + } + if got.OverallScore != successResult.OverallScore { + t.Errorf("overall_score = %v, want %v", got.OverallScore, successResult.OverallScore) + } + if got.RiskLevel != successResult.RiskLevel { + t.Errorf("risk_level = %q, want %q", got.RiskLevel, successResult.RiskLevel) + } + }) + + t.Run("valid request sets content-type to application/json", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want %q", got, "application/json") + } + }) + + t.Run("missing url returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "url") { + t.Errorf("error message = %q, want it to mention 'url'", got["error"]) + } + }) + + t.Run("missing html returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":""}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "html") { + t.Errorf("error message = %q, want it to mention 'html'", got["error"]) + } + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{not valid json}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got["error"] == "" { + t.Error("expected non-empty error message") + } + }) + + t.Run("empty body returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", nil) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + }) + + t.Run("analyzer timeout returns 504", func(t *testing.T) { + mock := &mockPipeline{err: context.DeadlineExceeded} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusGatewayTimeout { + t.Errorf("status code = %d, want %d", got, http.StatusGatewayTimeout) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "timed out") { + t.Errorf("error message = %q, want it to mention 'timed out'", got["error"]) + } + }) + + t.Run("wrapped deadline exceeded returns 504", func(t *testing.T) { + mock := &mockPipeline{err: fmt.Errorf("analyze: %w", context.DeadlineExceeded)} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusGatewayTimeout { + t.Errorf("status code = %d, want %d", got, http.StatusGatewayTimeout) + } + }) + + t.Run("analyzer error returns 502", func(t *testing.T) { + mock := &mockPipeline{err: errors.New("llm service unavailable")} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadGateway { + t.Errorf("status code = %d, want %d", got, http.StatusBadGateway) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "unavailable") { + t.Errorf("error message = %q, want it to mention 'unavailable'", got["error"]) + } + }) + + t.Run("non-policy result returns 200", func(t *testing.T) { + notPolicyResult := &analyzer.AnalysisResult{ + URL: "https://example.com/about", + RiskLevel: analyzer.RiskNotPolicy, + AnalyzedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + mock := &mockPipeline{result: notPolicyResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/about","html":"About Us"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusOK { + t.Errorf("status code = %d, want %d", got, http.StatusOK) + } + + var got analyzer.AnalysisResult + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.RiskLevel != analyzer.RiskNotPolicy { + t.Errorf("risk_level = %q, want %q", got.RiskLevel, analyzer.RiskNotPolicy) + } + }) +} + func TestHealthHandler(t *testing.T) { cfg := newTestConfig() - handler := NewHealthHandler(cfg) + handler := NewHealthHandler(cfg, nil) t.Run("returns 200 status", func(t *testing.T) { w := httptest.NewRecorder() @@ -80,14 +331,11 @@ func TestHealthHandler(t *testing.T) { if got.Services["openai"] != "configured" { t.Errorf("openai = %q, want %q", got.Services["openai"], "configured") } - if got.Services["qdrant"] != "localhost:6334" { - t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "localhost:6334") - } }) t.Run("reports not_configured when API keys are empty", func(t *testing.T) { emptyCfg := &config.Config{QdrantURL: "localhost:6334"} - h := NewHealthHandler(emptyCfg) + h := NewHealthHandler(emptyCfg, nil) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) @@ -106,4 +354,60 @@ func TestHealthHandler(t *testing.T) { t.Errorf("openai = %q, want %q", got.Services["openai"], "not_configured") } }) + + t.Run("qdrant connected when health check succeeds", func(t *testing.T) { + check := func(_ context.Context) error { return nil } + h := NewHealthHandler(cfg, check) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + h(w, r) + + var got struct { + Services map[string]string `json:"services"` + } + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.Services["qdrant"] != "connected" { + t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "connected") + } + }) + + t.Run("qdrant unavailable when health check fails", func(t *testing.T) { + check := func(_ context.Context) error { return errors.New("connection refused") } + h := NewHealthHandler(cfg, check) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + h(w, r) + + var got struct { + Services map[string]string `json:"services"` + } + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.Services["qdrant"] != "unavailable" { + t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "unavailable") + } + }) + + t.Run("qdrant shows URL when no health check provided", func(t *testing.T) { + h := NewHealthHandler(cfg, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + h(w, r) + + var got struct { + Services map[string]string `json:"services"` + } + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.Services["qdrant"] != "localhost:6334" { + t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "localhost:6334") + } + }) } diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index e367f17..80f3f6e 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -1,11 +1,20 @@ package api import ( + "context" "log/slog" "net/http" "time" + + "github.com/google/uuid" ) +// contextKey is an unexported type for context keys in this package, +// preventing collisions with keys defined in other packages. +type contextKey string + +const requestIDKey contextKey = "request_id" + // statusRecorder wraps http.ResponseWriter to capture the response status code. type statusRecorder struct { http.ResponseWriter @@ -46,6 +55,7 @@ func CORSMiddleware(next http.Handler) http.Handler { } // LoggingMiddleware returns middleware that logs request method, path, status code, and duration. +// If a request ID is present in the context, it is included in the log entry. func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() @@ -57,12 +67,62 @@ func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { next.ServeHTTP(sr, r) - logger.Info("request completed", + attrs := []any{ "method", r.Method, "path", r.URL.Path, "status", sr.statusCode, "duration", time.Since(start), - ) + } + + if id := RequestIDFromContext(r.Context()); id != "" { + attrs = append(attrs, "request_id", id) + } + + logger.Info("request completed", attrs...) }) } } + +// RequestIDMiddleware generates or propagates a unique request ID for each request. +// If the incoming request has an X-Request-ID header, that value is used. +// Otherwise, a new UUID v4 is generated. +// The request ID is set on the X-Request-ID response header and injected into the request context. +func RequestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + id = generateUUID() + } + + w.Header().Set("X-Request-ID", id) + + ctx := context.WithValue(r.Context(), requestIDKey, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequestIDFromContext extracts the request ID from the context. +// Returns an empty string if no request ID is present. +func RequestIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(requestIDKey).(string) + return id +} + +// TimeoutMiddleware returns middleware that wraps the request context with a deadline. +// If the handler does not complete within the given duration, the context is cancelled +// with context.DeadlineExceeded, allowing downstream handlers to detect the timeout. +func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// generateUUID generates a UUID v4 string using google/uuid. +func generateUUID() string { + return uuid.NewString() +} diff --git a/backend/internal/api/middleware_test.go b/backend/internal/api/middleware_test.go index 53da327..e081336 100644 --- a/backend/internal/api/middleware_test.go +++ b/backend/internal/api/middleware_test.go @@ -2,11 +2,14 @@ package api import ( "bytes" + "context" "log/slog" "net/http" "net/http/httptest" + "regexp" "strings" "testing" + "time" ) func TestCORSMiddleware(t *testing.T) { @@ -143,4 +146,212 @@ func TestLoggingMiddleware(t *testing.T) { t.Errorf("log output missing status=200\ngot: %s", output) } }) + + t.Run("includes request_id when present in context", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := LoggingMiddleware(logger)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test", nil) + + // Inject request ID into context (simulating RequestIDMiddleware upstream) + ctx := context.WithValue(r.Context(), requestIDKey, "test-request-id-456") + r = r.WithContext(ctx) + + handler.ServeHTTP(w, r) + + output := buf.String() + if !strings.Contains(output, "request_id=test-request-id-456") { + t.Errorf("log output missing request_id=test-request-id-456\ngot: %s", output) + } + }) + + t.Run("omits request_id when not in context", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := LoggingMiddleware(logger)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test", nil) + + handler.ServeHTTP(w, r) + + output := buf.String() + if strings.Contains(output, "request_id") { + t.Errorf("log output should not contain request_id when not in context\ngot: %s", output) + } + }) +} + +func TestRequestIDMiddleware(t *testing.T) { + t.Run("generates UUID when no X-Request-ID header present", func(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + got := w.Header().Get("X-Request-ID") + if got == "" { + t.Fatal("X-Request-ID header not set") + } + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + uuidPattern := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + if !uuidPattern.MatchString(got) { + t.Errorf("X-Request-ID = %q, does not match UUID v4 pattern", got) + } + }) + + t.Run("uses existing X-Request-ID header value", func(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Request-ID", "existing-id-123") + + handler.ServeHTTP(w, r) + + got := w.Header().Get("X-Request-ID") + if got != "existing-id-123" { + t.Errorf("X-Request-ID = %q, want %q", got, "existing-id-123") + } + }) + + t.Run("injects request ID into context", func(t *testing.T) { + var ctxID string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxID = RequestIDFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if ctxID == "" { + t.Fatal("RequestIDFromContext returned empty string") + } + + // The context ID should match the response header + headerID := w.Header().Get("X-Request-ID") + if ctxID != headerID { + t.Errorf("context ID = %q, header ID = %q, want them to match", ctxID, headerID) + } + }) + + t.Run("calls next handler", func(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if !called { + t.Error("next handler was not called") + } + }) +} + +func TestRequestIDFromContext(t *testing.T) { + t.Run("returns ID when present in context", func(t *testing.T) { + ctx := context.WithValue(context.Background(), requestIDKey, "test-id-789") + got := RequestIDFromContext(ctx) + if got != "test-id-789" { + t.Errorf("RequestIDFromContext = %q, want %q", got, "test-id-789") + } + }) + + t.Run("returns empty string when not in context", func(t *testing.T) { + got := RequestIDFromContext(context.Background()) + if got != "" { + t.Errorf("RequestIDFromContext = %q, want empty string", got) + } + }) +} + +func TestTimeoutMiddleware(t *testing.T) { + t.Run("sets context deadline", func(t *testing.T) { + timeout := 60 * time.Second + var hasDeadline bool + var deadline time.Time + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deadline, hasDeadline = r.Context().Deadline() + w.WriteHeader(http.StatusOK) + }) + handler := TimeoutMiddleware(timeout)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + before := time.Now() + handler.ServeHTTP(w, r) + + if !hasDeadline { + t.Fatal("context does not have a deadline") + } + + // Deadline should be approximately 60 seconds from now + expectedDeadline := before.Add(timeout) + diff := deadline.Sub(expectedDeadline) + if diff < -time.Second || diff > time.Second { + t.Errorf("deadline diff from expected = %v, want within ±1s", diff) + } + }) + + t.Run("context cancelled after deadline exceeded", func(t *testing.T) { + timeout := time.Millisecond + var ctxErr error + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Wait for the deadline to expire + time.Sleep(10 * time.Millisecond) + ctxErr = r.Context().Err() + w.WriteHeader(http.StatusOK) + }) + handler := TimeoutMiddleware(timeout)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if ctxErr != context.DeadlineExceeded { + t.Errorf("context error = %v, want %v", ctxErr, context.DeadlineExceeded) + } + }) + + t.Run("calls next handler", func(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + handler := TimeoutMiddleware(time.Second)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if !called { + t.Error("next handler was not called") + } + }) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 9cd149c..96ca0f2 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,20 +1,29 @@ package api import ( + "context" "log/slog" "net/http" + "time" "github.com/parth/smolterms/backend/internal/config" ) +// defaultRequestTimeout is the default deadline for request processing. +const defaultRequestTimeout = 60 * time.Second + // NewRouter creates and returns a fully configured HTTP handler with -// registered routes and middleware (CORS and request logging). +// registered routes and middleware. +// Middleware order (outermost to innermost): CORS -> Request ID -> Timeout -> Logging -> routes. // If logger is nil, the default slog logger is used. // If cfg is nil, the health handler uses zero-value config defaults. -func NewRouter(logger *slog.Logger, cfg *config.Config) http.Handler { +// If pipeline is nil, the analyze endpoint returns 503 Service Unavailable. +// qdrantCheck is an optional function for the health endpoint to verify Qdrant connectivity. +func NewRouter(logger *slog.Logger, cfg *config.Config, pipeline PipelineRunner, qdrantCheck func(ctx context.Context) error) http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/health", NewHealthHandler(cfg)) + mux.HandleFunc("GET /api/v1/health", NewHealthHandler(cfg, qdrantCheck)) + mux.HandleFunc("POST /api/v1/analyze", NewAnalyzeHandler(pipeline, logger)) // Catch-all for unmatched routes to return JSON 404. mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -23,6 +32,8 @@ func NewRouter(logger *slog.Logger, cfg *config.Config) http.Handler { var handler http.Handler = mux handler = LoggingMiddleware(logger)(handler) + handler = TimeoutMiddleware(defaultRequestTimeout)(handler) + handler = RequestIDMiddleware(handler) handler = CORSMiddleware(handler) return handler diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 6d9ff2c..eed1f1b 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -4,11 +4,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + + "github.com/parth/smolterms/backend/internal/analyzer" ) func TestNewRouter(t *testing.T) { - router := NewRouter(nil, nil) + router := NewRouter(nil, nil, nil, nil) server := httptest.NewServer(router) defer server.Close() @@ -83,4 +86,113 @@ func TestNewRouter(t *testing.T) { t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusNoContent) } }) + + t.Run("X-Request-ID header present on health endpoint", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/health") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + got := resp.Header.Get("X-Request-ID") + if got == "" { + t.Error("X-Request-ID header not set on response") + } + }) + + t.Run("X-Request-ID passthrough from client header", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/health", nil) + req.Header.Set("X-Request-ID", "client-trace-id") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + got := resp.Header.Get("X-Request-ID") + if got != "client-trace-id" { + t.Errorf("X-Request-ID = %q, want %q", got, "client-trace-id") + } + }) +} + +func TestNewRouterAnalyzeEndpoint(t *testing.T) { + successResult := &analyzer.AnalysisResult{ + URL: "https://example.com/privacy", + OverallScore: 7.5, + RiskLevel: analyzer.RiskModerate, + } + mock := &mockPipeline{result: successResult} + router := NewRouter(nil, nil, mock, nil) + server := httptest.NewServer(router) + defer server.Close() + + t.Run("POST /api/v1/analyze routes to handler", func(t *testing.T) { + body := `{"url":"https://example.com/privacy","html":"Policy"}` + resp, err := http.Post(server.URL+"/api/v1/analyze", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got analyzer.AnalysisResult + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + if got.RiskLevel != analyzer.RiskModerate { + t.Errorf("risk_level = %q, want %q", got.RiskLevel, analyzer.RiskModerate) + } + }) + + t.Run("GET /api/v1/analyze returns 405 or 404", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/analyze") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Go's ServeMux with method-based routing may return 404 (via the catch-all) + // or 405 depending on how routes are matched. Both are acceptable. + if resp.StatusCode != http.StatusMethodNotAllowed && resp.StatusCode != http.StatusNotFound { + t.Errorf("status code = %d, want 405 or 404", resp.StatusCode) + } + }) + + t.Run("OPTIONS /api/v1/analyze returns 204 with CORS", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodOptions, server.URL+"/api/v1/analyze", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusNoContent) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*") + } + }) +} + +func TestNewRouterWithNilPipeline(t *testing.T) { + // When no pipeline is provided, POST to analyze should return 503 + router := NewRouter(nil, nil, nil, nil) + server := httptest.NewServer(router) + defer server.Close() + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + resp, err := http.Post(server.URL+"/api/v1/analyze", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable) + } } diff --git a/go.mod b/go.mod index 68f59da..56ca538 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/PuerkitoBio/goquery v1.11.0 github.com/anthropics/anthropic-sdk-go v1.26.0 + github.com/google/uuid v1.6.0 github.com/openai/openai-go v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/qdrant/go-client v1.16.0