diff --git a/AGENTS.md b/AGENTS.md index 5dae9788..f4849377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This document defines execution constraints for AI agents. For general contribut ## Agent-Specific Verification Rules - Always run Dev Container smoke + `make ready` for changes. +- For frontend changes or API contract/spec changes, run `make ci-ready` to mirror CI parity checks. - For frontend changes, also verify in `chrome-devtools` MCP at `http://127.0.0.1:4001/` while the Dev Container is running. - Capture a quick state check for all affected UI states (e.g., guest/member/result) to enforce state parity and avoid duplicate actions. diff --git a/Makefile b/Makefile index 130f89c2..e2bae4ac 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ # frozen_string_literal: true -.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e +.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e + +RUBOCOP_FLAGS ?= --cache false # Default target help: ## Show this help message @@ -60,7 +62,7 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found @echo "Running RuboCop linting..." - bundle exec rubocop + bundle exec rubocop $(RUBOCOP_FLAGS) @echo "Running Zeitwerk eager-load check..." bundle exec rake zeitwerk:verify @echo "Running YARD public-method docs check..." @@ -105,6 +107,13 @@ ready: ## Pre-commit gate (quick checks + RSpec) bundle exec rspec @echo "Pre-commit checks complete!" +ci-ready: ## CI parity gate (ready + OpenAPI verify + frontend e2e smoke) + @echo "Running CI parity checks..." + $(MAKE) ready + $(MAKE) openapi-verify + $(MAKE) test-frontend-e2e + @echo "CI parity checks complete!" + yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/ bundle exec rake yard:verify_public_docs diff --git a/app.rb b/app.rb index 6115126c..6da19d82 100644 --- a/app.rb +++ b/app.rb @@ -13,6 +13,21 @@ module Html2rss module Web + DEFAULT_HEADERS = { + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Permitted-Cross-Domain-Policies' => 'none', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()', + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + 'Cross-Origin-Embedder-Policy' => 'require-corp', + 'Cross-Origin-Opener-Policy' => 'same-origin', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'X-DNS-Prefetch-Control' => 'off', + 'X-Download-Options' => 'noopen' + }.freeze + ## # Roda app serving RSS feeds via html2rss class App < Roda @@ -41,9 +56,14 @@ def development? = self.class.development? use Rack::Cache, metastore: 'file:./tmp/rack-cache-meta', entitystore: 'file:./tmp/rack-cache-body', verbose: development? + # rubocop:disable Metrics/BlockLength plugin :content_security_policy do |csp| csp.default_src :none - csp.style_src :self, "'unsafe-inline'" + if development? + csp.style_src :self, "'unsafe-inline'" + else + csp.style_src :self + end csp.script_src :self csp.connect_src :self csp.img_src :self @@ -65,21 +85,9 @@ def development? = self.class.development? csp.block_all_mixed_content csp.upgrade_insecure_requests end + # rubocop:enable Metrics/BlockLength - plugin :default_headers, { - 'X-Content-Type-Options' => 'nosniff', - 'X-XSS-Protection' => '1; mode=block', - 'X-Frame-Options' => 'SAMEORIGIN', - 'X-Permitted-Cross-Domain-Policies' => 'none', - 'Referrer-Policy' => 'strict-origin-when-cross-origin', - 'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()', - 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', - 'Cross-Origin-Embedder-Policy' => 'require-corp', - 'Cross-Origin-Opener-Policy' => 'same-origin', - 'Cross-Origin-Resource-Policy' => 'same-origin', - 'X-DNS-Prefetch-Control' => 'off', - 'X-Download-Options' => 'noopen' - } + plugin :default_headers, DEFAULT_HEADERS plugin :json_parser plugin :static, @@ -98,9 +106,20 @@ def development? = self.class.development? route do |r| r.public + r.root do + if development? + render_development_api_landing(r) + else + render_index_page(r) + end + end Routes::ApiV1.call(r) || - Routes::FeedPages.call(r, index_renderer: ->(router_ctx) { render_index_page(router_ctx) }) + Routes::FeedPages.call( + r, + index_renderer: ->(router_ctx) { render_index_page(router_ctx) }, + serve_spa: !development? + ) end private @@ -109,6 +128,11 @@ def render_index_page(router) router.response['Content-Type'] = 'text/html' File.exist?(FRONTEND_INDEX_PATH) ? File.read(FRONTEND_INDEX_PATH) : FALLBACK_HTML end + + def render_development_api_landing(router) + router.response['Content-Type'] = 'text/html' + DevelopmentLandingPage::HTML + end end end end diff --git a/app/web/api/v1/contract.rb b/app/web/api/v1/contract.rb index bc67beb8..00e53c4f 100644 --- a/app/web/api/v1/contract.rb +++ b/app/web/api/v1/contract.rb @@ -4,17 +4,158 @@ module Html2rss module Web module Api module V1 - module Contract + ## + # Shared API v1 contract constants and payload builders. + module Contract # rubocop:disable Metrics/ModuleLength CODES = { unauthorized: Html2rss::Web::UnauthorizedError::CODE, forbidden: Html2rss::Web::ForbiddenError::CODE, internal_server_error: Html2rss::Web::InternalServerError::CODE }.freeze + ERROR_KINDS = { + auth: 'auth', + input: 'input', + network: 'network', + server: 'server' + }.freeze + + NEXT_ACTIONS = { + enter_token: 'enter_token', + correct_input: 'correct_input', + retry: 'retry', + wait: 'wait', + none: 'none' + }.freeze + + RETRY_ACTIONS = { + alternate: 'alternate', + primary: 'primary', + none: 'none' + }.freeze + + READINESS_PHASES = { + link_created: 'link_created', + feed_ready: 'feed_ready', + feed_not_ready_yet: 'feed_not_ready_yet', + preview_unavailable: 'preview_unavailable' + }.freeze + + PREVIEW_STATUSES = { + pending: 'pending', + ready: 'ready', + degraded: 'degraded', + unavailable: 'unavailable' + }.freeze + MESSAGES = { auto_source_disabled: 'Auto source feature is disabled', health_check_failed: 'Health check failed' }.freeze + + class << self + # Builds the structured API error envelope used by JSON API routes. + # + # @param error [StandardError] + # @return [Hash{Symbol=>Object}] structured API error details. + def failure_payload(error) + metadata = failure_metadata(error) + base = { + message: client_message_for(error), + code: error_code_for(error), + kind: metadata[:kind], + retryable: metadata[:retryable], + next_action: metadata[:next_action], + retry_action: metadata[:retry_action] + } + metadata[:next_strategy] ? base.merge(next_strategy: metadata[:next_strategy]) : base + end + + # Builds a warning entry for readiness/status responses. + # + # @param code [String] + # @param message [String] + # @param retryable [Boolean] + # @param next_action [String] + # @return [Hash{Symbol=>Object}] structured warning payload. + def warning(code:, message:, retryable:, next_action:) + { + code: code, + message: message, + retryable: retryable, + next_action: next_action + } + end + + private + + # @param error [StandardError] + # @return [Hash{Symbol=>Object}] + def failure_metadata(error) + case error + when Html2rss::Web::AutoSourceDisabledError, Html2rss::Web::HealthCheckFailedError + non_retryable_server_failure_metadata + when Html2rss::Web::UnauthorizedError then auth_failure_metadata + when Html2rss::Web::BadRequestError, Html2rss::Web::ForbiddenError then input_failure_metadata + else + generic_failure_metadata(error) + end + end + + # @param error [StandardError] + # @return [Hash{Symbol=>Object}] + def generic_failure_metadata(error) + kind = Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server + { + kind: ERROR_KINDS[kind], + retryable: true, + next_action: NEXT_ACTIONS[:retry], + retry_action: RETRY_ACTIONS[:primary] + } + end + + # @return [Hash{Symbol=>Object}] + def auth_failure_metadata + { + kind: ERROR_KINDS[:auth], + retryable: false, + next_action: NEXT_ACTIONS[:enter_token], + retry_action: RETRY_ACTIONS[:none] + } + end + + # @return [Hash{Symbol=>Object}] + def input_failure_metadata + { + kind: ERROR_KINDS[:input], + retryable: false, + next_action: NEXT_ACTIONS[:correct_input], + retry_action: RETRY_ACTIONS[:none] + } + end + + # @return [Hash{Symbol=>Object}] + def non_retryable_server_failure_metadata + { + kind: ERROR_KINDS[:server], + retryable: false, + next_action: NEXT_ACTIONS[:none], + retry_action: RETRY_ACTIONS[:none] + } + end + + # @param error [StandardError] + # @return [String] + def client_message_for(error) + error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE + end + + # @param error [StandardError] + # @return [String] + def error_code_for(error) + error.respond_to?(:code) ? error.code : CODES[:internal_server_error] + end + end end end end diff --git a/app/web/api/v1/create_feed.rb b/app/web/api/v1/create_feed.rb index ec96a76a..5fb4e3ba 100644 --- a/app/web/api/v1/create_feed.rb +++ b/app/web/api/v1/create_feed.rb @@ -11,18 +11,21 @@ module V1 # Creates stable feed records from authenticated API requests. module CreateFeed # rubocop:disable Metrics/ModuleLength FEED_ATTRIBUTE_KEYS = - %i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze + %i[id name url feed_token public_url json_public_url created_at updated_at].freeze class << self # rubocop:disable Metrics/ClassLength # Creates a feed and returns a normalized API success payload. # # @param request [Rack::Request] HTTP request with auth context. # @return [Hash{Symbol=>Object}] API response payload. - def call(request) + def call(request) # rubocop:disable Metrics/MethodLength params, feed_data = build_feed_from_request(request) emit_create_success(params) Response.success(response: request.response, status: 201, - data: { feed: feed_attributes(feed_data) }, + data: { + feed: feed_attributes(feed_data), + conversion: FeedStatus.initial_conversion + }, meta: { created: true }) rescue StandardError => error emit_create_failure(error) @@ -33,7 +36,7 @@ def call(request) # @return [void] def ensure_auto_source_enabled! - raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? + raise Html2rss::Web::AutoSourceDisabledError unless AutoSource.enabled? end # @param request [Rack::Request] @@ -52,8 +55,7 @@ def build_create_params(params, account) url = validated_url(params['url'], account) FeedMetadata::CreateParams.new( url: url, - name: FeedMetadata.site_title_for(url), - strategy: normalize_strategy(params['strategy']) + name: FeedMetadata.site_title_for(url) ) end @@ -101,33 +103,6 @@ def hostname_input?(url) }ix.match?(url) end - # @param raw_strategy [String, nil] - # @return [String] - def normalize_strategy(raw_strategy) - strategy = raw_strategy.to_s.strip - strategy = default_strategy if strategy.empty? - - raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy) - - strategy - end - - # @return [Array] supported strategy identifiers. - def supported_strategies - Html2rss::RequestService.strategy_names.map(&:to_s) - end - - # @param strategy [String] - # @return [Boolean] - def supported_strategy?(strategy) - supported_strategies.include?(strategy) - end - - # @return [String] default strategy identifier. - def default_strategy - Html2rss::RequestService.default_strategy_name.to_s - end - # @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata] # @return [Hash{Symbol=>Object}] def feed_attributes(feed_data) @@ -167,7 +142,7 @@ def build_feed_from_request(request) ensure_auto_source_enabled! params = build_create_params(request_params(request), account) - feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy) + feed_data = AutoSource.create_stable_feed(params.name, params.url, account) raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data [params, feed_data] @@ -179,7 +154,7 @@ def emit_create_success(params) Observability.emit( event_name: 'feed.create', outcome: 'success', - details: { strategy: params.strategy, url: params.url }, + details: { url: params.url }, level: :info ) end diff --git a/app/web/api/v1/feed_metadata.rb b/app/web/api/v1/feed_metadata.rb index dde42b33..b3f5a9a6 100644 --- a/app/web/api/v1/feed_metadata.rb +++ b/app/web/api/v1/feed_metadata.rb @@ -64,10 +64,10 @@ def json_public_url(feed_token) ## # Feed create parameters contract. - CreateParams = Data.define(:url, :name, :strategy) do + CreateParams = Data.define(:url, :name) do # @return [Hash{Symbol=>Object}] def to_h - { url: url, name: name, strategy: strategy } + { url: url, name: name } end end diff --git a/app/web/api/v1/feed_status.rb b/app/web/api/v1/feed_status.rb new file mode 100644 index 00000000..afe26469 --- /dev/null +++ b/app/web/api/v1/feed_status.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Api + module V1 + ## + # Builds structured readiness metadata for token-backed feed status checks. + module FeedStatus # rubocop:disable Metrics/ModuleLength + class << self # rubocop:disable Metrics/ClassLength + # Returns the initial conversion state immediately after feed creation. + # + # @return [Hash{Symbol=>Object}] structured readiness payload. + def initial_conversion + { + readiness_phase: Contract::READINESS_PHASES[:link_created], + preview_status: Contract::PREVIEW_STATUSES[:pending], + warnings: [] + } + end + + # Resolves a token-backed feed status snapshot and writes a JSON response. + # + # @param request [Rack::Request] HTTP request carrying API context. + # @param token [String] encoded feed token from the path. + # @return [Hash{Symbol=>Object}] normalized API response body. + def call(request, token:) + feed_request, resolved_source, result = resolve_status(request, token) + + Response.success( + response: request.response, + data: { + feed: feed_attributes(feed_request, resolved_source, result, token), + conversion: conversion_attributes(result) + } + ) + end + + private + + # @param request [Rack::Request] + # @param token [String] + # @return [Array] request, resolved source, and render result. + def resolve_status(request, token) + feed_request = Html2rss::Web::Feeds::Request.call( + request: request, + target_kind: :token, + identifier: token + ) + resolved_source = Html2rss::Web::Feeds::SourceResolver.call(feed_request) + result = Html2rss::Web::Feeds::Service.call(resolved_source) + [feed_request, resolved_source, result] + end + + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param token [String] + # @return [Hash{Symbol=>Object}] + def feed_attributes(feed_request, resolved_source, result, token) + url = resolved_source.generator_input.dig(:channel, :url) + site_title = result.payload&.site_title || FeedMetadata.site_title_for(url) || url.to_s + feed_token = feed_request.token || token + + { + name: site_title, + url: url, + feed_token: feed_token, + public_url: public_url(feed_token), + json_public_url: json_public_url(feed_token) + } + end + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [Hash{Symbol=>Object}] + def conversion_attributes(result) # rubocop:disable Metrics/MethodLength + case result.status + when :ok + { + readiness_phase: Contract::READINESS_PHASES[:feed_ready], + preview_status: Contract::PREVIEW_STATUSES[:ready], + warnings: [] + } + when :empty + { + readiness_phase: Contract::READINESS_PHASES[:feed_ready], + preview_status: Contract::PREVIEW_STATUSES[:degraded], + warnings: [ + Contract.warning( + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + next_action: Contract::NEXT_ACTIONS[:retry] + ) + ] + } + when :error + if result.error_kind == :network + transient_conversion_attributes + else + unavailable_conversion_attributes + end + else + unavailable_conversion_attributes + end + end + + # @return [Hash{Symbol=>Object}] + def transient_conversion_attributes # rubocop:disable Metrics/MethodLength + { + readiness_phase: Contract::READINESS_PHASES[:feed_not_ready_yet], + preview_status: Contract::PREVIEW_STATUSES[:pending], + warnings: [ + Contract.warning( + code: 'PREVIEW_NOT_READY', + message: 'Feed is still preparing.', + retryable: true, + next_action: Contract::NEXT_ACTIONS[:wait] + ) + ] + } + end + + # @return [Hash{Symbol=>Object}] + def unavailable_conversion_attributes # rubocop:disable Metrics/MethodLength + { + readiness_phase: Contract::READINESS_PHASES[:preview_unavailable], + preview_status: Contract::PREVIEW_STATUSES[:unavailable], + warnings: [ + Contract.warning( + code: 'PREVIEW_UNAVAILABLE', + message: 'Preview unavailable right now.', + retryable: false, + next_action: Contract::NEXT_ACTIONS[:none] + ) + ] + } + end + + # @param token [String] + # @return [String] + def public_url(token) + "/api/v1/feeds/#{token}" + end + + # @param token [String] + # @return [String] + def json_public_url(token) + "#{public_url(token)}.json" + end + end + end + end + end + end +end diff --git a/app/web/api/v1/health.rb b/app/web/api/v1/health.rb index 41299803..81f889ce 100644 --- a/app/web/api/v1/health.rb +++ b/app/web/api/v1/health.rb @@ -96,7 +96,7 @@ def bearer_token(request) def verify_configuration! LocalConfig.yaml rescue StandardError - raise Html2rss::Web::InternalServerError, Contract::MESSAGES[:health_check_failed] + raise Html2rss::Web::HealthCheckFailedError end end end diff --git a/app/web/boot/sentry.rb b/app/web/boot/sentry.rb index e69dc82b..a07c5150 100644 --- a/app/web/boot/sentry.rb +++ b/app/web/boot/sentry.rb @@ -28,6 +28,7 @@ def initialize_sentry! ::Sentry.init do |config| apply_settings(config) end + apply_scope_tags! end # @param config [Object] @@ -45,6 +46,20 @@ def release_name "#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}" end + # @return [void] + def apply_scope_tags! + return unless defined?(::Sentry) && ::Sentry.respond_to?(:configure_scope) + + ::Sentry.configure_scope do |scope| + scope.set_tags( + release: release_name, + environment: RuntimeEnv.rack_env + ) + end + rescue StandardError + nil + end + # @return [Boolean] def sentry_initialized? defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized? diff --git a/app/web/error_classification.rb b/app/web/error_classification.rb new file mode 100644 index 00000000..6cd14bb8 --- /dev/null +++ b/app/web/error_classification.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Classifies runtime failures into coarse transport classes for API contracts. + module ErrorClassification + NETWORK_ERROR_CLASS_NAMES = %w[ + Timeout::Error + Net::OpenTimeout + Net::ReadTimeout + SocketError + EOFError + Errno::ECONNREFUSED + Errno::EHOSTUNREACH + Errno::ETIMEDOUT + ].freeze + + class << self + # @param error [StandardError, nil] + # @return [Boolean] + def network_error?(error) + unwrap(error).any? { |candidate| NETWORK_ERROR_CLASS_NAMES.include?(candidate.class.name) } + end + + private + + # @param error [StandardError, nil] + # @return [Array] + def unwrap(error) + errors = [] + seen = {}.compare_by_identity + current = error + + while current && !seen[current] + errors << current + seen[current] = true + current = current.respond_to?(:cause) ? current.cause : nil + end + + errors + end + end + end + end +end diff --git a/app/web/errors/auto_source_disabled_error.rb b/app/web/errors/auto_source_disabled_error.rb new file mode 100644 index 00000000..4472cb6f --- /dev/null +++ b/app/web/errors/auto_source_disabled_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 403 error raised when feed creation is disabled by instance policy. + class AutoSourceDisabledError < ForbiddenError + DEFAULT_MESSAGE = Api::V1::Contract::MESSAGES[:auto_source_disabled] + end + end +end diff --git a/app/web/errors/error_responder.rb b/app/web/errors/error_responder.rb index 4deefe59..2f86f5f5 100644 --- a/app/web/errors/error_responder.rb +++ b/app/web/errors/error_responder.rb @@ -25,7 +25,7 @@ def respond(request:, response:, error:) client_message = client_message_for(error) return render_feed_error(request, response, client_message) if RequestTarget.feed?(request) - return render_api_error(response, client_message, error_code) if api_request?(request) + return render_api_error(response, error) if api_request?(request) render_xml_error(response, client_message) end @@ -46,12 +46,11 @@ def api_path?(request) end # @param response [Rack::Response] - # @param message [String] - # @param code [String] + # @param error [StandardError] # @return [String] JSON error payload. - def render_api_error(response, message, code) + def render_api_error(response, error) response['Content-Type'] = 'application/json' - JSON.generate({ success: false, error: { message: message, code: code } }) + JSON.generate({ success: false, error: Api::V1::Contract.failure_payload(error) }) end # @param response [Rack::Response] diff --git a/app/web/errors/health_check_failed_error.rb b/app/web/errors/health_check_failed_error.rb new file mode 100644 index 00000000..e0009020 --- /dev/null +++ b/app/web/errors/health_check_failed_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 500 error raised when shallow health checks cannot read configuration. + class HealthCheckFailedError < InternalServerError + DEFAULT_MESSAGE = Api::V1::Contract::MESSAGES[:health_check_failed] + end + end +end diff --git a/app/web/feeds/contracts.rb b/app/web/feeds/contracts.rb index 29aa24cd..a3d098f0 100644 --- a/app/web/feeds/contracts.rb +++ b/app/web/feeds/contracts.rb @@ -20,7 +20,7 @@ module Contracts ## # Shared feed-serving result wrapper. - RenderResult = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key, :error_message) + RenderResult = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key, :error_message, :error_kind) end end end diff --git a/app/web/feeds/responder.rb b/app/web/feeds/responder.rb index 329fdece..7901e3aa 100644 --- a/app/web/feeds/responder.rb +++ b/app/web/feeds/responder.rb @@ -61,7 +61,7 @@ def emit_response_result(target_kind:, identifier:, feed_request:, resolved_sour # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] # @return [String] def write_response(response:, representation:, result:) - response.status = result.status == :error ? 500 : 200 + response.status = status_for(result.status) response['Content-Type'] = FeedResponseFormat.content_type(representation) apply_cache_headers(response, result) ::Html2rss::Web::HttpCache.vary(response, 'Accept') @@ -92,7 +92,8 @@ def render_result(result, representation) # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] # @return [void] def emit_result(target_kind:, identifier:, resolved_source:, result:) - return emit_success(target_kind:, identifier:, resolved_source:) unless result.status == :error + return emit_success(target_kind:, identifier:, resolved_source:) if result.status == :ok + return emit_empty(target_kind:, identifier:, resolved_source:) if result.status == :empty emit_failure( target_kind:, @@ -117,6 +118,21 @@ def emit_success(target_kind:, identifier:, resolved_source:) Observability.emit(event_name: 'feed.render', outcome: 'success', details:, level: :info) end + # @param target_kind [Symbol] + # @param identifier [String] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @return [void] + def emit_empty(target_kind:, identifier:, resolved_source:) + details = { + strategy: resolved_source.generator_input[:strategy], + url: resolved_source.generator_input.dig(:channel, :url), + reason: 'content_extraction_empty' + } + details[:feed_name] = identifier if target_kind == :static + + Observability.emit(event_name: 'feed.render', outcome: 'failure', details:, level: :warn) + end + # @param target_kind [Symbol] # @param identifier [String] # @param error [StandardError] @@ -127,6 +143,15 @@ def emit_failure(target_kind:, identifier:, error:) Observability.emit(event_name: 'feed.render', outcome: 'failure', details:, level: :warn) end + + # @param status [Symbol] + # @return [Integer] + def status_for(status) + return 200 if status == :ok + return 422 if status == :empty + + 500 + end end end end diff --git a/app/web/feeds/service.rb b/app/web/feeds/service.rb index f7bee826..dfa957df 100644 --- a/app/web/feeds/service.rb +++ b/app/web/feeds/service.rb @@ -44,7 +44,8 @@ def success_result(feed, resolved_source, cache_key) message: nil, ttl_seconds: resolved_source.ttl_seconds, cache_key: cache_key, - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -93,7 +94,8 @@ def error_result(error, resolved_source, cache_key) message: Html2rss::Web::HttpError::DEFAULT_MESSAGE, ttl_seconds: resolved_source.ttl_seconds, cache_key: cache_key, - error_message: error.message + error_message: error.message, + error_kind: Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server ) end end diff --git a/app/web/rendering/development_landing_page.rb b/app/web/rendering/development_landing_page.rb new file mode 100644 index 00000000..81206250 --- /dev/null +++ b/app/web/rendering/development_landing_page.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Static HTML shown on the API origin during development. + module DevelopmentLandingPage + HTML = <<~HTML + + + + html2rss-web API + + + + +

html2rss-web API (development)

+

API metadata: /api/v1

+

Frontend SPA: http://127.0.0.1:4001/

+ + + HTML + end + end +end diff --git a/app/web/rendering/feed_notice_text.rb b/app/web/rendering/feed_notice_text.rb index ec92ac3c..ed34d3ad 100644 --- a/app/web/rendering/feed_notice_text.rb +++ b/app/web/rendering/feed_notice_text.rb @@ -6,19 +6,17 @@ module Web # Shared copy helpers for rendered feed warnings and fallback documents. module FeedNoticeText EMPTY_FEED_DESCRIPTION_TEMPLATE = <<~DESC - Unable to extract content from %s using the %s strategy. - The site may rely on JavaScript, block automated requests, or expose a structure that needs a different parser. + We could not extract entries from %s right now. + The source may block automated requests, require dynamic rendering, or be temporarily unavailable. DESC EMPTY_FEED_ITEM_TEMPLATE = <<~DESC No entries were extracted from %s. - Possible causes: - - JavaScript-heavy site (try the browserless strategy) - - Anti-bot protection - - Complex or changing markup - - Site blocking automated requests - Try another strategy or reach out to the site owner. + What you can do: + - Try again in a few moments + - Open the original page to confirm content is available + - Reach out to the site owner if access is restricted DESC class << self diff --git a/app/web/rendering/json_feed_builder.rb b/app/web/rendering/json_feed_builder.rb index 0c1e3187..727c4b99 100644 --- a/app/web/rendering/json_feed_builder.rb +++ b/app/web/rendering/json_feed_builder.rb @@ -75,7 +75,7 @@ def build_single_item(item) # @return [Hash{Symbol=>String}] def empty_feed_item(url) { - title: 'Content Extraction Failed', + title: 'Preview unavailable for this source', content_text: FeedNoticeText.empty_feed_item(url: url), url: url } diff --git a/app/web/rendering/xml_builder.rb b/app/web/rendering/xml_builder.rb index 399a4d39..057e997b 100644 --- a/app/web/rendering/xml_builder.rb +++ b/app/web/rendering/xml_builder.rb @@ -53,7 +53,7 @@ def build_empty_feed_warning(url:, strategy:, site_title: nil) build_single_item_feed( title: FeedNoticeText.empty_feed_title(site_title), description: FeedNoticeText.empty_feed_description(url: url, strategy: strategy), - item: { title: 'Content Extraction Failed', description: FeedNoticeText.empty_feed_item(url: url), + item: { title: 'Preview unavailable for this source', description: FeedNoticeText.empty_feed_item(url: url), link: url }, link: url ) diff --git a/app/web/routes/api_v1/feed_routes.rb b/app/web/routes/api_v1/feed_routes.rb index fdfd7c93..5303622e 100644 --- a/app/web/routes/api_v1/feed_routes.rb +++ b/app/web/routes/api_v1/feed_routes.rb @@ -10,11 +10,17 @@ module FeedRoutes class << self # @param router [Roda::RodaRequest] # @return [void] - def call(router) + def call(router) # rubocop:disable Metrics/MethodLength router.on 'feeds' do - router.get String do |token| - RequestTarget.mark!(router, RequestTarget::FEED) - Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + router.on String do |token| + router.get 'status' do + JSON.generate(Api::V1::FeedStatus.call(router, token: token)) + end + + router.get do + RequestTarget.mark!(router, RequestTarget::FEED) + Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + end end router.post do diff --git a/app/web/routes/feed_pages.rb b/app/web/routes/feed_pages.rb index 535329f6..dc66fa75 100644 --- a/app/web/routes/feed_pages.rb +++ b/app/web/routes/feed_pages.rb @@ -6,24 +6,32 @@ module Routes ## # Mounts the root page and legacy feed paths. module FeedPages + SPA_APP_PATHS = %w[create token result].freeze + SPA_APP_PREFIXES = ['result/'].freeze + class << self # @param router [Roda::RodaRequest] # @param index_renderer [#call] + # @param serve_spa [Boolean] # @return [void] - def call(router, index_renderer:) - router.root do - index_renderer.call(router) - end - + # rubocop:disable Metrics/MethodLength + def call(router, index_renderer:, serve_spa: true) router.get do feed_name = requested_feed_name(router) next if feed_name.empty? + + if spa_app_path?(feed_name) && serve_spa + index_renderer.call(router) + next + end + next if spa_app_path?(feed_name) next if feed_name.include?('.') && !feed_name.end_with?('.json', '.xml', '.rss') RequestTarget.mark!(router, RequestTarget::FEED) Feeds::Responder.call(request: router, target_kind: :static, identifier: feed_name) end end + # rubocop:enable Metrics/MethodLength private @@ -32,6 +40,12 @@ def call(router, index_renderer:) def requested_feed_name(router) router.path_info.to_s.delete_prefix('/') end + + # @param path [String] + # @return [Boolean] + def spa_app_path?(path) + SPA_APP_PATHS.include?(path) || SPA_APP_PREFIXES.any? { |prefix| path.start_with?(prefix) } + end end end end diff --git a/app/web/telemetry/app_logger.rb b/app/web/telemetry/app_logger.rb index 8724bab6..88d686ad 100644 --- a/app/web/telemetry/app_logger.rb +++ b/app/web/telemetry/app_logger.rb @@ -3,11 +3,8 @@ require 'json' require 'logger' require 'time' - module Html2rss module Web - ## - # Shared structured logger for application and middleware runtime events. module AppLogger class << self # @return [Logger] @@ -103,6 +100,7 @@ def normalize_logfmt_value(raw_value) def emit_to_sentry(payload) return unless sentry_payload?(payload) + SentryLogs.record_breadcrumb(payload) SentryLogs.emit(payload) rescue StandardError nil diff --git a/app/web/telemetry/sentry_logs.rb b/app/web/telemetry/sentry_logs.rb index f6d55353..33839677 100644 --- a/app/web/telemetry/sentry_logs.rb +++ b/app/web/telemetry/sentry_logs.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative '../security/log_sanitizer' + module Html2rss module Web ## @@ -9,8 +11,26 @@ module SentryLogs OMIT = Object.new.freeze ALLOWED_LEVELS = %i[debug info warn error fatal].freeze SENSITIVE_ATTRIBUTE_KEYS = %w[actor email ip remote_ip user_agent username x_forwarded_for].freeze + BREADCRUMB_KEYS = %i[event_name security_event outcome request_id route_group strategy component details].freeze + BREADCRUMB_CATEGORY_KEYS = %i[event_name security_event component].freeze + BREADCRUMB_MESSAGE_KEYS = %i[message event_name security_event component].freeze class << self + # @param payload [Hash{Symbol=>Object}] + # @return [void] + def record_breadcrumb(payload) + return unless breadcrumb_enabled? + + ::Sentry.add_breadcrumb( + category: breadcrumb_category(payload), + message: breadcrumb_message(payload), + level: breadcrumb_level(payload), + data: breadcrumb_data(payload) + ) + rescue StandardError + nil + end + # @param payload [Hash{Symbol=>Object}] # @return [void] def emit(payload) @@ -31,6 +51,13 @@ def enabled? !logger.nil? end + # @return [Boolean] + def breadcrumb_enabled? + RuntimeEnv.sentry_enabled? && + defined?(::Sentry) && + ::Sentry.respond_to?(:add_breadcrumb) + end + # @return [Object, nil] def logger return unless defined?(::Sentry) && ::Sentry.respond_to?(:logger) @@ -54,6 +81,34 @@ def message(payload) payload[:component] || 'html2rss-web log' end + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def breadcrumb_category(payload) + breadcrumb_label(payload, 'html2rss-web', BREADCRUMB_CATEGORY_KEYS) + end + + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def breadcrumb_message(payload) + breadcrumb_label(payload, 'html2rss-web log', BREADCRUMB_MESSAGE_KEYS) + end + + # @param payload [Hash{Symbol=>Object}] + # @return [String] + def breadcrumb_level(payload) + requested_level = payload.fetch(:level, 'info').to_s.downcase + return 'warning' if requested_level == 'warn' + return requested_level if ALLOWED_LEVELS.map(&:to_s).include?(requested_level) + + 'info' + end + + # @param payload [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def breadcrumb_data(payload) + LogSanitizer.sanitize_details(payload).slice(*BREADCRUMB_KEYS) + end + # @param payload [Hash{Symbol=>Object}] # @return [Hash{Symbol=>Object}] def attributes(payload) @@ -98,6 +153,14 @@ def sanitize_array(key, values) def sensitive_key?(key) SENSITIVE_ATTRIBUTE_KEYS.include?(key.to_s) end + + # @param payload [Hash{Symbol=>Object}] + # @param fallback [String] + # @param keys [Array] + # @return [String] + def breadcrumb_label(payload, fallback, keys) + keys.lazy.map { |key| payload[key] }.find(&:itself) || fallback + end end end end diff --git a/docs/README.md b/docs/README.md index ba66490a..f584c5fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,7 @@ Welcome! This is the canonical source of truth for contributing to `html2rss-web `html2rss-web` converts arbitrary websites into RSS 2.0 feeds. - **Backend**: Ruby + Roda under the `Html2rss::Web` namespace. -- **Frontend**: Preact + Vite, built into `frontend/dist` and served at `/`. +- **Frontend**: Preact + Vite, built into `frontend/dist` and served at `/` in production. - **Feed extraction**: Delegated to the `html2rss` gem. - **Distribution**: Docker Compose / Dev Container first. @@ -42,6 +42,7 @@ Running the app directly on the host is not supported. | `make setup` | Install Ruby and Node dependencies. | | `make dev` | Run Ruby (port 4000) and frontend (port 4001) dev servers. | | `make ready` | Pre-commit gate: `make quick-check` + `bundle exec rspec`. | +| `make ci-ready` | CI parity gate: `make ready` + `make openapi-verify` + frontend e2e smoke. | | `make test` | Run Ruby and frontend test suites. | | `make lint` | Run all linters. | | `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. | @@ -59,6 +60,12 @@ Running the app directly on the host is not supported. | `pnpm run test:run` | Unit tests (Vitest). | | `pnpm run test:contract`| Contract tests with MSW. | +Development routing defaults: + +- `http://127.0.0.1:4000` is API-only in development (`/api/v1` metadata and API endpoints). +- `http://127.0.0.1:4001` is the canonical frontend SPA entrypoint in development. +- Vite keeps proxying `/api` and `/rss.xsl` to `:4000` so frontend code can use same-origin-style paths. + --- ## Contract-Driven Development Loop @@ -84,6 +91,12 @@ Always run this before pushing or committing: make ready ``` +For frontend changes and API contract/OpenAPI changes, run the CI-parity gate: + +```bash +make ci-ready +``` + ### Testing Layers | Layer | Tooling | Focus | @@ -191,6 +204,25 @@ Canonical event fields: `event_name`, `schema_version`, `request_id`, `route_gro Critical-path event families: auth, feed create, feed render, request errors. +## Sentry Runbook + +When `SENTRY_DSN` is present, Sentry is enabled. `BUILD_TAG` and `GIT_SHA` become the release identifier, and +`RACK_ENV` becomes the environment tag. + +First-15-minute triage checklist: + +1. Open the newest `feed.create`, `feed.render`, and `request.error` events. +2. Confirm the release tag matches the deployed build. +3. Check the breadcrumb trail for the failing path, strategy, and outcome. +4. Decide whether the failure is retryable or terminal before paging the user-facing incident path. + +Alert baseline: + +1. Page on sustained `request.error` spikes in production. +2. Page on a repeated `feed.render` failure burst, especially when success drops to zero for a route or strategy. +3. Track the first recovery signal after fallback or retry succeeds so the incident can be closed quickly. +4. Keep the initial threshold simple; tune after a few real incidents instead of pre-optimizing for every edge case. + --- ## Documentation Policy diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 0e8b731c..677c74c3 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -51,7 +51,6 @@ test.describe('frontend smoke', () => { await expect(page.getByLabel('Page URL')).toBeVisible(); await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'More' })).toBeVisible(); await page.getByLabel('Page URL').fill('https://example.com/articles'); await page.getByRole('button', { name: 'Generate feed URL' }).click(); @@ -63,6 +62,118 @@ test.describe('frontend smoke', () => { await page.getByRole('button', { name: 'Back' }).click(); await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'More' })).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(/\/create(?:\?.*)?$/); + await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible(); + await expect(page.locator('.form-shell')).toHaveAttribute('data-state', 'idle'); + }); + + test('restores result deep links and shows a recovery state when snapshot is missing', async ({ page }) => { + await page.route(/\/api\/v1$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, + }, + featured_feeds: [], + }, + }, + }), + }); + }); + + await page.route(/\/api\/v1\/strategies$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + strategies: [ + { id: 'faraday', name: 'faraday', display_name: 'Default' }, + { + id: 'browserless', + name: 'browserless', + display_name: 'JavaScript pages (recommended)', + }, + ], + }, + meta: { total: 2 }, + }), + }); + }); + + await page.addInitScript(() => { + localStorage.setItem( + 'html2rss_feed_result_snapshot:generated-token', + JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + strategy: 'faraday', + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + created_at: '2026-04-05T08:59:00.000Z', + updated_at: '2026-04-05T09:00:00.000Z', + }, + preview: { + items: [ + { + title: 'Sample preview item', + excerpt: 'Current restore snapshots include preview content.', + publishedLabel: 'April 5, 2026', + url: 'https://example.com/articles/sample-preview-item', + }, + ], + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }) + ); + }); + + await page.goto('/result/generated-token'); + + await expect(page.getByRole('heading', { name: 'Feed ready' })).toBeVisible(); + await expect(page.locator('.result-shell')).toHaveAttribute('data-state', 'ready'); + await expect(page.getByText('Example Feed')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open feed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open JSON Feed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open in feed reader' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create another feed' })).toBeVisible(); + await expect(page.getByText('Sample preview item')).toBeVisible(); + await expect(page.getByText('Current restore snapshots include preview content.')).toBeVisible(); + + await page.evaluate(() => { + localStorage.removeItem('html2rss_feed_result_snapshot:missing-token'); + }); + + await page.goto('/result/missing-token'); + + await expect(page.getByText('Saved result unavailable')).toBeVisible(); + await expect( + page.getByText('We could not restore this feed result. Create a new feed link to continue.') + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Go to create' })).toBeVisible(); + await expect(page.locator('.notice')).toHaveAttribute('data-tone', 'error'); }); }); diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 79150dce..acabf3bc 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -1,24 +1,25 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; import { http, HttpResponse } from 'msw'; -import { server, buildFeedResponse } from './mocks/server'; +import { server, buildFeedResponse, buildStructuredErrorResponse } from './mocks/server'; import { App } from '../components/App'; describe('App contract', () => { const token = 'contract-token'; - const authenticate = () => { - globalThis.localStorage.setItem('html2rss_access_token', token); - }; - - it('shows feed result when API responds with success', async () => { - authenticate(); + beforeEach(() => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); + globalThis.sessionStorage.setItem('html2rss_access_token', token); + }); + it('shows feed result when the API returns structured create and status payloads', async () => { server.use( http.post('/api/v1/feeds', async ({ request }) => { - const body = (await request.json()) as { url: string; strategy: string }; + const body = (await request.json()) as { url: string }; - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' }); + expect(body).toEqual({ url: 'https://example.com/articles' }); expect(request.headers.get('authorization')).toBe(`Bearer ${token}`); return HttpResponse.json( @@ -27,9 +28,29 @@ describe('App contract', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', - }) + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }), + { status: 201 } ); }), + http.get('/api/v1/feeds/generated-token/status', () => + HttpResponse.json( + buildFeedResponse({ + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], + }, + }) + ) + ), http.get('/api/v1/feeds/generated-token.json', ({ request }) => { expect(request.headers.get('accept')).toBe('application/feed+json'); @@ -53,103 +74,47 @@ describe('App contract', () => { render(); - await screen.findByLabelText('Page URL'); await waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement; fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready'); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( - 'href', - 'http://localhost:3000/api/v1/feeds/generated-token.json' - ); expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.getByText('Preview')).toBeInTheDocument(); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); - expect(screen.getByText('Contract Item')).toBeInTheDocument(); }); }); - it('loads instance metadata from /api/v1 without trailing slash', async () => { - let slashlessMetadataRequests = 0; - let trailingSlashMetadataRequests = 0; - - server.use( - http.get('/api/v1', () => { - slashlessMetadataRequests += 1; - - return HttpResponse.json({ - success: true, - data: { - api: { - name: 'html2rss-web API', - description: 'RESTful API for converting websites to RSS feeds', - openapi_url: 'http://example.test/openapi.yaml', - }, - instance: { - feed_creation: { - enabled: true, - access_token_required: true, - }, - featured_feeds: [], - }, - }, - }); - }), - http.get('/api/v1/', () => { - trailingSlashMetadataRequests += 1; - - return HttpResponse.text('', { status: 404 }); - }) - ); - - render(); - - await screen.findByLabelText('Page URL'); - - expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument(); - expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument(); - expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1); - expect(trailingSlashMetadataRequests).toBe(0); - }); - - it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => { - server.use( - http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })), - http.get('/api/v1/', () => HttpResponse.text('', { status: 404 })) - ); - - render(); - - await screen.findByText('Instance metadata unavailable'); - - expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument(); - }); - - it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => { - authenticate(); - + it('reopens token recovery when a saved token is rejected by structured auth metadata', async () => { server.use( http.post('/api/v1/feeds', async () => - HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 }) + HttpResponse.json( + buildStructuredErrorResponse({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', + }), + { status: 401 } + ) ) ); render(); - await screen.findByLabelText('Page URL'); await waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); }); fireEvent.input(screen.getByLabelText('Page URL'), { @@ -160,7 +125,7 @@ describe('App contract', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); expect(screen.getByText('Enter access token')).toBeInTheDocument(); - expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); + expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument(); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); }); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 5f58cfa3..7e4fa6ca 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -14,19 +14,32 @@ vi.mock('../hooks/useApiMetadata', () => ({ useApiMetadata: vi.fn(), })); -vi.mock('../hooks/useStrategies', () => ({ - useStrategies: vi.fn(), -})); - import { useAccessToken } from '../hooks/useAccessToken'; import { useApiMetadata } from '../hooks/useApiMetadata'; import { useFeedConversion } from '../hooks/useFeedConversion'; -import { useStrategies } from '../hooks/useStrategies'; const mockUseAccessToken = useAccessToken as any; const mockUseApiMetadata = useApiMetadata as any; const mockUseFeedConversion = useFeedConversion as any; -const mockUseStrategies = useStrategies as any; +const mockCreatedFeedResult = { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + }, + preview: { + items: [], + error: undefined, + isLoading: true, + }, + readinessPhase: 'link_created', + previewStatus: 'pending', + warnings: [], + retry: undefined, +}; describe('App', () => { const mockSaveToken = vi.fn(); @@ -35,10 +48,13 @@ describe('App', () => { const mockClearConversionError = vi.fn(); const mockClearResult = vi.fn(); const mockRetryReadinessCheck = vi.fn(); + const mockRestoreResult = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); + globalThis.localStorage.clear(); + mockConvertFeed.mockResolvedValue(mockCreatedFeedResult); mockUseAccessToken.mockReturnValue({ token: undefined, @@ -76,15 +92,7 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, - }); - - mockUseStrategies.mockReturnValue({ - strategies: [ - { id: 'faraday', name: 'faraday', display_name: 'Default' }, - { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, - ], - isLoading: false, - error: undefined, + restoreResult: mockRestoreResult, }); }); @@ -92,10 +100,12 @@ describe('App', () => { render(); expect(screen.getByLabelText('html2rss')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'html2rss' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'html2rss' })).toHaveAttribute('href', '/create'); expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Utilities')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument(); + expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'idle'); }); it('keeps the page url field permissive enough for hostname-only input', () => { @@ -116,29 +126,29 @@ describe('App', () => { }); }); - it('prefers faraday as the default strategy when available', () => { - render(); - - return waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); - }); - }); - - it('falls back to the first available strategy when browserless is unavailable', () => { - mockUseStrategies.mockReturnValue({ - strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }], + it('submits create requests without exposing strategy selection', async () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, isLoading: false, error: undefined, }); render(); - return waitFor(() => { - expect(screen.getByRole('combobox')).toHaveValue('faraday'); + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await waitFor(() => { + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); }); }); - it('auto-submits a prefilled url using the resolved default strategy', async () => { + it('auto-submits a prefilled url without persisting strategy state', async () => { mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -156,7 +166,60 @@ describe('App', () => { render(); await waitFor(() => { - expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'saved-token'); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); + expect(globalThis.location.pathname).toBe('/result/generated-token'); + }); + }); + + it('restores result state from local snapshot when opening a result deep link', async () => { + globalThis.localStorage.setItem( + 'html2rss_feed_result_snapshot:generated-token', + JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + created_at: '2026-04-05T08:59:00.000Z', + updated_at: '2026-04-05T09:00:00.000Z', + }, + preview: { + items: [], + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }) + ); + globalThis.history.replaceState({}, '', 'http://localhost:3000/result/generated-token'); + + render(); + + await waitFor(() => { + expect(mockRestoreResult).toHaveBeenCalledTimes(1); + expect(mockRestoreResult).toHaveBeenCalledWith( + expect.objectContaining({ + feed: expect.objectContaining({ feed_token: 'generated-token' }), + readinessPhase: 'feed_ready', + }) + ); + }); + }); + + it('shows a recovery notice when result deep link has no snapshot to restore', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/result/missing-token'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Saved result unavailable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go to create' })).toBeInTheDocument(); }); }); @@ -169,9 +232,11 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); expect(screen.getByText('Enter access token')).toBeInTheDocument(); + expect(globalThis.location.pathname).toBe('/token'); + expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'token_required'); expect(screen.getByLabelText('Page URL')).toBeDisabled(); - expect(screen.getByRole('combobox')).toBeDisabled(); - expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Utilities')).not.toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument(); expect(screen.getByText('Required by this instance.')).toBeInTheDocument(); expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument(); @@ -218,6 +283,7 @@ describe('App', () => { }); it('renders the result panel when a feed is available', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/result/example-token'); mockUseFeedConversion.mockReturnValue({ isConverting: false, result: { @@ -225,17 +291,24 @@ describe('App', () => { id: 'feed-123', name: 'Example Feed', url: 'https://example.com/articles', - strategy: 'faraday', feed_token: 'example-token', public_url: '/api/v1/feeds/example-token', json_public_url: '/api/v1/feeds/example-token.json', }, preview: { items: [], - error: 'Preview unavailable right now.', isLoading: false, }, readinessPhase: 'preview_unavailable', + previewStatus: 'unavailable', + warnings: [ + { + code: 'preview_unavailable', + message: 'Preview unavailable right now.', + retryable: false, + next_action: 'none', + }, + ], retry: undefined, }, error: undefined, @@ -243,30 +316,45 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); render(); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'failed'); expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); - expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getAllByText('Preview unavailable right now.').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); + return waitFor(() => { + expect(globalThis.location.pathname).toBe('/create'); + }); }); it('surfaces conversion errors to the user', () => { mockUseFeedConversion.mockReturnValue({ isConverting: false, result: undefined, - error: 'Access denied', + error: { + kind: 'auth', + code: 'UNAUTHORIZED', + retryable: false, + nextAction: 'enter_token', + retryAction: 'none', + message: 'Access denied', + }, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); render(); - expect(screen.getByText('Could not create feed link')).toBeInTheDocument(); + expect(screen.getByText("Couldn't create feed yet")).toBeInTheDocument(); expect(screen.getByText('Access denied')).toBeInTheDocument(); }); @@ -279,6 +367,7 @@ describe('App', () => { clearError: mockClearConversionError, clearResult: mockClearResult, retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, }); render(); @@ -299,7 +388,6 @@ describe('App', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); fireEvent.click(screen.getByRole('button', { name: 'Clear saved token' })); expect(mockClearToken).toHaveBeenCalled(); @@ -317,8 +405,6 @@ describe('App', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityItems = [ ...screen .getByLabelText('Utilities') @@ -328,10 +414,10 @@ describe('App', () => { expect(utilityItems).toEqual([ 'Try included feeds', 'Bookmarklet', + 'Clear saved token', + 'Install from Docker Hub', 'OpenAPI spec', 'Source code', - 'Install from Docker Hub', - 'Clear saved token', ]); }); @@ -348,7 +434,7 @@ describe('App', () => { await waitFor(() => { expect(mockSaveToken).toHaveBeenCalledWith('token-123'); - expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'token-123'); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'token-123'); }); }); @@ -361,7 +447,13 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); + mockConvertFeed.mockRejectedValueOnce( + Object.assign(new Error('Unauthorized'), { + code: 'UNAUTHORIZED', + status: 401, + kind: 'auth', + }) + ); render(); @@ -389,7 +481,13 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); + mockConvertFeed.mockRejectedValueOnce( + Object.assign(new Error('Unauthorized'), { + code: 'UNAUTHORIZED', + status: 401, + kind: 'auth', + }) + ); render(); @@ -401,7 +499,10 @@ describe('App', () => { await screen.findByText('Access token was rejected. Paste a valid token to continue.'); fireEvent.click(screen.getByRole('button', { name: 'Back' })); - expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument(); + await waitFor(() => { + expect(globalThis.location.pathname).toBe('/create'); + }); + expect(screen.queryByText("Couldn't create feed yet")).not.toBeInTheDocument(); expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument(); }); @@ -423,12 +524,11 @@ describe('App', () => { }); it('builds a bookmarklet that returns to the root app entry', () => { - globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); const bookmarklet = screen.getByRole('link', { name: 'Bookmarklet' }); - expect(bookmarklet.getAttribute('href')).toContain('/?url='); + expect(bookmarklet.getAttribute('href')).toContain('/create?url='); expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent'); }); @@ -438,11 +538,30 @@ describe('App', () => { render(); await screen.findByText('Enter access token'); + expect(globalThis.location.pathname).toBe('/token'); expect(screen.getByLabelText('Page URL')).toHaveValue('https://example.com/articles'); expect(mockConvertFeed).not.toHaveBeenCalled(); }); - it('offers a direct alternate strategy retry after conversion failure', async () => { + it('shows generic retry action for alternate retry metadata and reruns create', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'server', + code: 'INTERNAL_SERVER_ERROR', + retryable: true, + nextAction: 'retry', + retryAction: 'alternate', + nextStrategy: 'browserless', + message: 'Browserless failed.', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -451,34 +570,39 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed - .mockRejectedValueOnce( - Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - manualRetryStrategy: 'browserless', - }) - ) - .mockResolvedValueOnce(); render(); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); - await screen.findByRole('button', { name: 'Retry with browserless' }); - fireEvent.click(screen.getByRole('button', { name: 'Retry with browserless' })); + expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); await waitFor(() => { - expect(mockConvertFeed).toHaveBeenLastCalledWith( - 'https://example.com/articles', - 'browserless', - 'saved-token' - ); + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); }); }); - it('does not offer a duplicate retry action after automatic fallback already failed', async () => { + it('shows Try again for primary retry metadata and reruns the create flow', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'server', + code: 'INTERNAL_SERVER_ERROR', + retryable: true, + nextAction: 'retry', + retryAction: 'primary', + message: 'Browserless failed.', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -487,24 +611,37 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce( - Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { - manualRetryStrategy: '', - }) - ); render(); fireEvent.input(screen.getByLabelText('Page URL'), { target: { value: 'https://example.com/articles' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); - await screen.findByText('Tried faraday first, then browserless. Browserless failed.'); - expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'saved-token'); + }); }); it('does not treat non-token forbidden failures as token rejection or strategy-recovery UX', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: undefined, + error: { + kind: 'server', + code: 'FORBIDDEN', + retryable: false, + nextAction: 'none', + retryAction: 'none', + message: 'URL not allowed for this account', + }, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + retryReadinessCheck: mockRetryReadinessCheck, + restoreResult: mockRestoreResult, + }); mockUseAccessToken.mockReturnValue({ token: 'saved-token', hasToken: true, @@ -513,43 +650,32 @@ describe('App', () => { isLoading: false, error: undefined, }); - mockConvertFeed.mockRejectedValueOnce( - Object.assign(new Error('URL not allowed for this account'), { - manualRetryStrategy: 'browserless', - }) - ); render(); - fireEvent.input(screen.getByLabelText('Page URL'), { - target: { value: 'https://example.com/articles' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - await screen.findByText('URL not allowed for this account'); expect(mockClearToken).not.toHaveBeenCalled(); expect(screen.queryByText('Enter access token')).not.toBeInTheDocument(); expect( screen.queryByText('Access token was rejected. Paste a valid token to continue.') ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Retry with .*/ })).not.toBeInTheDocument(); }); it('shows the utility links in a user-focused order', () => { - globalThis.history.replaceState({}, '', 'http://localhost:3000/#result'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityLinks = [ ...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'), ].map((link) => link.textContent); expect(utilityLinks).toEqual([ 'Try included feeds', 'Bookmarklet', + 'Install from Docker Hub', 'OpenAPI spec', 'Source code', - 'Install from Docker Hub', ]); expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute( @@ -586,13 +712,21 @@ describe('App', () => { error: undefined, }); - globalThis.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/create'); render(); - fireEvent.click(screen.getByRole('button', { name: 'More' })); expect(screen.getByRole('link', { name: 'OpenAPI spec' })).toHaveAttribute( 'href', 'http://localhost:3000/openapi.yaml' ); }); + + it('shows footer utilities on result routes', async () => { + globalThis.history.replaceState({}, '', 'http://localhost:3000/result/generated-token'); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Utilities')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index d830ac69..ef3c0cd9 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -10,7 +10,6 @@ describe('ResultDisplay', () => { id: 'test-id', name: 'Test Feed', url: 'https://example.com', - strategy: 'faraday', feed_token: 'test-feed-token', public_url: 'https://example.com/feed.xml', json_public_url: 'https://example.com/feed.json', @@ -39,6 +38,13 @@ describe('ResultDisplay', () => { isLoading: false, }, readinessPhase: 'feed_ready' as const, + previewStatus: 'ready' as const, + warnings: [] as Array<{ + code: string; + message: string; + retryable: boolean; + nextAction: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; + }>, retry: undefined, }; @@ -49,16 +55,18 @@ describe('ResultDisplay', () => { it('renders the success state actions and richer preview cards', async () => { render( ); + expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready'); expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Test Feed')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open feed' })).toHaveClass('btn--primary'); expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute( 'href', 'https://example.com/feed.json' @@ -77,14 +85,61 @@ describe('ResultDisplay', () => { }); }); + it('surfaces degraded preview metadata when the API marks the result as degraded', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Feed ready')).toBeInTheDocument(); + expect(screen.getByText('Preview content could not be fully verified.')).toBeInTheDocument(); + expect( + screen.getByText('Feed is ready, but preview content is partially degraded right now.') + ).toBeInTheDocument(); + expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); + }); + }); + it('surfaces feed-not-ready state with a readiness retry action', async () => { render( @@ -92,9 +147,9 @@ describe('ResultDisplay', () => { await waitFor(() => { expect(screen.getByText('Feed still warming up')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Try readiness check again' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try readiness check again' })).toHaveClass('btn--primary'); expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); - expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument(); expect(screen.getByText('Latest items from this feed')).toBeInTheDocument(); }); }); @@ -102,11 +157,16 @@ describe('ResultDisplay', () => { it('keeps result shell visible while readiness check is in progress', async () => { render( @@ -114,6 +174,7 @@ describe('ResultDisplay', () => { await waitFor(() => { expect(screen.getByText('Feed created')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Checking readiness…' })).toHaveClass('btn--primary'); expect(screen.getByRole('button', { name: 'Checking readiness…' })).toBeDisabled(); expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument(); @@ -123,26 +184,28 @@ describe('ResultDisplay', () => { it('shows an automatic retry notice when fallback strategy succeeded', async () => { render( ); await waitFor(() => { - expect( - screen.getByText('Retried automatically with browserless after faraday could not finish the page.') - ).toBeInTheDocument(); + expect(screen.getByText('Feed creation recovered automatically.')).toBeInTheDocument(); }); }); it('calls onCreateAnother when the reset button is clicked', () => { render( @@ -156,7 +219,8 @@ describe('ResultDisplay', () => { it('calls onRetryReadiness when the readiness action is clicked', () => { render( @@ -169,7 +233,8 @@ describe('ResultDisplay', () => { it('copies feed URL to clipboard when copy button is clicked', async () => { render( diff --git a/frontend/src/__tests__/feedWorkflowStorage.test.ts b/frontend/src/__tests__/feedWorkflowStorage.test.ts new file mode 100644 index 00000000..d5cabb88 --- /dev/null +++ b/frontend/src/__tests__/feedWorkflowStorage.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearFeedDraftState, + clearFeedResultSnapshot, + loadFeedDraftState, + loadFeedResultSnapshot, + loadFeedResultState, + saveFeedDraftState, + saveFeedResultSnapshot, +} from '../utils/feedWorkflowStorage'; + +describe('feedWorkflowStorage', () => { + beforeEach(() => { + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); + }); + + it('persists and hydrates the create draft state from the url only', () => { + saveFeedDraftState({ url: 'https://example.com/articles' }); + + expect(loadFeedDraftState()).toEqual({ + url: 'https://example.com/articles', + }); + expect(globalThis.localStorage.getItem('html2rss_feed_draft_state')).toBe( + JSON.stringify({ url: 'https://example.com/articles' }) + ); + + clearFeedDraftState(); + expect(loadFeedDraftState()).toBeUndefined(); + }); + + it('ignores extra draft properties beyond the canonical shape', () => { + globalThis.localStorage.setItem( + 'html2rss_feed_draft_state', + JSON.stringify({ + url: 'https://example.com/articles', + extra: 'ignored', + }) + ); + + expect(loadFeedDraftState()).toEqual({ + url: 'https://example.com/articles', + }); + }); + + it('persists and hydrates the latest feed result snapshot by token', () => { + const result = { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: [], + isLoading: true, + }, + readinessPhase: 'link_created' as const, + previewStatus: 'pending' as const, + warnings: [], + retry: undefined, + }; + + saveFeedResultSnapshot(result); + + expect(loadFeedResultSnapshot('example-token')).toMatchObject({ + savedAt: expect.any(String), + result, + }); + expect(loadFeedResultState('example-token')).toMatchObject(result); + + clearFeedResultSnapshot('example-token'); + expect(loadFeedResultSnapshot('example-token')).toBeUndefined(); + }); + + it.each([ + { + name: 'invalid JSON', + value: '{not-json', + }, + { + name: 'missing preview', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + { + name: 'non-array preview items', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: undefined, + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + { + name: 'malformed preview item', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: [undefined], + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + { + name: 'malformed feed shape', + value: JSON.stringify({ + savedAt: '2026-04-05T09:00:00.000Z', + result: { + feed: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'example-token', + json_public_url: '/api/v1/feeds/example-token.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + preview: { + items: [], + isLoading: false, + }, + readinessPhase: 'feed_ready', + previewStatus: 'ready', + warnings: [], + }, + }), + }, + ])('rejects $name snapshots without throwing', ({ value }) => { + globalThis.localStorage.setItem('html2rss_feed_result_snapshot:example-token', value); + + expect(() => loadFeedResultSnapshot('example-token')).not.toThrow(); + expect(loadFeedResultSnapshot('example-token')).toBeUndefined(); + expect(loadFeedResultState('example-token')).toBeUndefined(); + }); +}); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 00359bc6..eecba140 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -40,6 +40,9 @@ export const server = setupServer( }, meta: { total: 2 }, }); + }), + http.get('/api/v1/feeds/:token/status', () => { + return HttpResponse.json(buildFeedStatusResponse()); }) ); @@ -47,16 +50,50 @@ export interface FeedResponseOverrides { id?: string; name?: string; url?: string; - strategy?: string; feed_token?: string; public_url?: string; json_public_url?: string; created_at?: string; updated_at?: string; + conversion?: ConversionResponseOverrides; +} + +export interface ConversionWarning { + code: string; + message: string; + retryable: boolean; + next_action: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; +} + +export interface ConversionResponseOverrides { + readiness_phase?: 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; + preview_status?: 'pending' | 'ready' | 'degraded' | 'unavailable'; + warnings?: ConversionWarning[]; + retry?: { + automatic: boolean; + from: string; + to: string; + }; +} + +export interface StructuredErrorOverrides { + code?: string; + message?: string; + kind?: 'auth' | 'input' | 'network' | 'server'; + retryable?: boolean; + next_action?: 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; + retry_action?: 'alternate' | 'primary' | 'none'; + next_strategy?: string; } export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { const timestamp = overrides.created_at ?? new Date('2024-01-01T00:00:00Z').toISOString(); + const conversion = { + readiness_phase: overrides.conversion?.readiness_phase ?? 'link_created', + preview_status: overrides.conversion?.preview_status ?? 'pending', + warnings: overrides.conversion?.warnings ?? [], + ...(overrides.conversion?.retry ? { retry: overrides.conversion.retry } : {}), + }; return { success: true, @@ -65,14 +102,44 @@ export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { id: overrides.id ?? 'feed-123', name: overrides.name ?? 'Example Feed', url: overrides.url ?? 'https://example.com/articles', - strategy: overrides.strategy ?? 'faraday', feed_token: overrides.feed_token ?? 'example-token', public_url: overrides.public_url ?? '/api/v1/feeds/example-token', json_public_url: overrides.json_public_url ?? '/api/v1/feeds/example-token.json', created_at: timestamp, updated_at: overrides.updated_at ?? timestamp, }, + conversion, }, meta: { created: true }, }; } + +export function buildFeedStatusResponse(overrides: FeedResponseOverrides = {}) { + return { + success: true, + data: { + ...buildFeedResponse(overrides).data, + conversion: { + readiness_phase: overrides.conversion?.readiness_phase ?? 'feed_ready', + preview_status: overrides.conversion?.preview_status ?? 'ready', + warnings: overrides.conversion?.warnings ?? [], + ...(overrides.conversion?.retry ? { retry: overrides.conversion.retry } : {}), + }, + }, + }; +} + +export function buildStructuredErrorResponse(overrides: StructuredErrorOverrides = {}) { + return { + success: false, + error: { + code: overrides.code ?? 'INTERNAL_SERVER_ERROR', + message: overrides.message ?? 'Internal Server Error', + kind: overrides.kind ?? 'server', + retryable: overrides.retryable ?? false, + next_action: overrides.next_action ?? 'none', + retry_action: overrides.retry_action ?? 'none', + ...(overrides.next_strategy ? { next_strategy: overrides.next_strategy } : {}), + }, + }; +} diff --git a/frontend/src/__tests__/useAccessToken.test.ts b/frontend/src/__tests__/useAccessToken.test.ts index 29f1f59b..b399f849 100644 --- a/frontend/src/__tests__/useAccessToken.test.ts +++ b/frontend/src/__tests__/useAccessToken.test.ts @@ -4,12 +4,11 @@ import { useAccessToken } from '../hooks/useAccessToken'; describe('useAccessToken', () => { beforeEach(() => { - globalThis.localStorage.clear(); globalThis.sessionStorage.clear(); }); - it('loads the persisted token from localStorage', async () => { - globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); + it('loads the persisted token from sessionStorage', async () => { + globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token'); const { result } = renderHook(() => useAccessToken()); @@ -19,18 +18,7 @@ describe('useAccessToken', () => { expect(result.current.error).toBeUndefined(); }); - it('migrates a legacy session token into localStorage', async () => { - globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); - - const { result } = renderHook(() => useAccessToken()); - - expect(result.current.isLoading).toBe(false); - expect(result.current.token).toBe('legacy-token'); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('legacy-token'); - expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); - }); - - it('saves new tokens to the persistent storage path', async () => { + it('saves new tokens to sessionStorage only', async () => { const { result } = renderHook(() => useAccessToken()); await act(async () => { @@ -39,13 +27,11 @@ describe('useAccessToken', () => { expect(result.current.token).toBe('new-token'); expect(result.current.hasToken).toBe(true); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('new-token'); - expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBe('new-token'); }); - it('clears both persistent and legacy token copies', async () => { - globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); - globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); + it('clears the canonical session token copy', async () => { + globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token'); const { result } = renderHook(() => useAccessToken()); @@ -55,7 +41,47 @@ describe('useAccessToken', () => { expect(result.current.token).toBeUndefined(); expect(result.current.hasToken).toBe(false); - expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull(); expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull(); }); + + it('falls back to in-memory token when sessionStorage write is unavailable', async () => { + globalThis.sessionStorage.setItem.mockImplementationOnce(() => { + throw new Error('blocked'); + }); + + const { result } = renderHook(() => useAccessToken()); + + await act(async () => { + await result.current.saveToken('memory-token'); + }); + + expect(result.current.token).toBe('memory-token'); + expect(result.current.hasToken).toBe(true); + }); + + it('loads from in-memory fallback when sessionStorage read is unavailable', async () => { + globalThis.sessionStorage.setItem.mockImplementationOnce(() => { + throw new Error('blocked'); + }); + + const seeded = renderHook(() => useAccessToken()); + await act(async () => { + await seeded.result.current.saveToken('memory-only'); + }); + seeded.unmount(); + + globalThis.sessionStorage.getItem.mockImplementationOnce(() => { + throw new Error('blocked'); + }); + + const { result } = renderHook(() => useAccessToken()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.token).toBe('memory-only'); + expect(result.current.hasToken).toBe(true); + expect(result.current.error).toBeUndefined(); + act(() => { + result.current.clearToken(); + }); + }); }); diff --git a/frontend/src/__tests__/useAuth.test.ts b/frontend/src/__tests__/useAuth.test.ts deleted file mode 100644 index 02ea4b2d..00000000 --- a/frontend/src/__tests__/useAuth.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook, act } from '@testing-library/preact'; -import { useAuth } from '../hooks/useAuth'; - -type MockedStorage = Storage & { - getItem: ReturnType; - setItem: ReturnType; - removeItem: ReturnType; - clear: ReturnType; -}; - -const createStorageMock = (): MockedStorage => { - return { - length: 0, - clear: vi.fn(), - getItem: vi.fn(), - key: vi.fn(), - removeItem: vi.fn(), - setItem: vi.fn(), - } as unknown as MockedStorage; -}; - -let localStorageMock: MockedStorage; -let sessionStorageMock: MockedStorage; - -describe('useAuth', () => { - beforeEach(() => { - localStorageMock = createStorageMock(); - sessionStorageMock = createStorageMock(); - Object.defineProperty(globalThis, 'localStorage', { - value: localStorageMock, - configurable: true, - writable: true, - }); - Object.defineProperty(globalThis, 'sessionStorage', { - value: sessionStorageMock, - configurable: true, - writable: true, - }); - vi.clearAllMocks(); - }); - - it('should initialize with unauthenticated state', () => { - localStorageMock.getItem.mockReturnValue(); - - const { result } = renderHook(() => useAuth()); - - expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeUndefined(); - expect(result.current.token).toBeUndefined(); - }); - - it('should load auth state from sessionStorage on mount', () => { - localStorageMock.getItem - .mockReturnValueOnce('testuser') // username - .mockReturnValueOnce('testtoken'); // token - - const { result } = renderHook(() => useAuth()); - - expect(result.current.isAuthenticated).toBe(true); - expect(result.current.username).toBe('testuser'); - expect(result.current.token).toBe('testtoken'); - expect(localStorageMock.getItem).toHaveBeenCalledWith('html2rss_username'); - expect(localStorageMock.getItem).toHaveBeenCalledWith('html2rss_token'); - }); - - it('should login and store credentials', async () => { - localStorageMock.getItem.mockReturnValue(); - - const { result } = renderHook(() => useAuth()); - - await act(async () => { - result.current.login('newuser', 'newtoken'); - }); - - expect(result.current.isAuthenticated).toBe(true); - expect(result.current.username).toBe('newuser'); - expect(result.current.token).toBe('newtoken'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('html2rss_username', 'newuser'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('html2rss_token', 'newtoken'); - }); - - it('should logout and clear credentials', () => { - localStorageMock.getItem.mockReturnValueOnce('testuser').mockReturnValueOnce('testtoken'); - - const { result } = renderHook(() => useAuth()); - - act(() => { - result.current.logout(); - }); - - expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeUndefined(); - expect(result.current.token).toBeUndefined(); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_username'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_token'); - }); -}); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index ac13df99..f056731c 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/preact'; import { http, HttpResponse } from 'msw'; -import { server, buildFeedResponse } from './mocks/server'; +import { server, buildFeedResponse, buildStructuredErrorResponse } from './mocks/server'; import { useFeedConversion } from '../hooks/useFeedConversion'; describe('useFeedConversion contract', () => { - it('sends feed creation request with bearer token', async () => { + it('sends feed creation requests with bearer auth and a url-only body', async () => { let receivedAuthorization: string | undefined; server.use( http.post('/api/v1/feeds', async ({ request }) => { - const body = (await request.json()) as { url: string; strategy: string }; + const body = (await request.json()) as { url: string }; receivedAuthorization = request.headers.get('authorization'); - expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' }); + expect(body).toEqual({ url: 'https://example.com/articles' }); return HttpResponse.json( buildFeedResponse({ @@ -21,52 +21,62 @@ describe('useFeedConversion contract', () => { feed_token: 'generated-token', public_url: '/api/v1/feeds/generated-token', json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, }), { status: 201 } ); }), - http.get('/api/v1/feeds/generated-token.json', ({ request }) => { - expect(request.headers.get('accept')).toBe('application/feed+json'); - - return HttpResponse.json({ - items: [ - { - title: 'Generated item', - content_text: 'Contract preview', - url: 'https://example.com/items/generated', - date_published: '2024-01-02T00:00:00Z', + http.get('/api/v1/feeds/generated-token/status', () => + HttpResponse.json( + buildFeedResponse({ + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], }, - ], - }); - }) + }) + ) + ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'test-token-123'); + await result.current.convertFeed('https://example.com/articles', 'test-token-123'); }); expect(receivedAuthorization).toBe('Bearer test-token-123'); expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); - expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token'); - expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json'); expect(result.current.result?.readinessPhase).toBe('link_created'); + await waitFor(() => { expect(result.current.result?.readinessPhase).toBe('feed_ready'); - expect(result.current.result?.preview.error).toBeUndefined(); - expect(result.current.result?.preview.isLoading).toBe(false); - expect(result.current.result?.preview.items).toHaveLength(1); + expect((result.current.result as any)?.previewStatus).toBe('ready'); + expect((result.current.result as any)?.warnings).toEqual([]); }); }); - it('propagates API validation errors', async () => { + it('propagates structured auth failures without parsing the message text', async () => { server.use( http.post('/api/v1/feeds', async () => HttpResponse.json( - { success: false, error: { message: 'URL parameter is required' } }, - { status: 400 } + buildStructuredErrorResponse({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', + }), + { status: 401 } ) ) ); @@ -74,70 +84,123 @@ describe('useFeedConversion contract', () => { const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'token') - ).rejects.toThrow('URL parameter is required'); + await expect(result.current.convertFeed('https://example.com/articles', 'token')).rejects.toMatchObject( + { + message: 'Authentication required', + } + ); }); expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('URL parameter is required'); + expect(result.current.error).toMatchObject({ + kind: 'auth', + code: 'UNAUTHORIZED', + nextAction: 'enter_token', + retryAction: 'none', + retryable: false, + message: 'Authentication required', + }); }); - it('normalizes malformed successful responses', async () => { + it('marks degraded result metadata when the status endpoint reports warnings', async () => { server.use( - http.post('/api/v1/feeds', async () => - HttpResponse.text('not-json', { - status: 200, - headers: { 'content-type': 'application/json' }, - }) + http.post('/api/v1/feeds', async ({ request }) => { + const body = (await request.json()) as { url: string }; + + return HttpResponse.json( + buildFeedResponse({ + url: body.url, + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }), + { status: 201 } + ); + }), + http.get('/api/v1/feeds/generated-token/status', () => + HttpResponse.json( + buildFeedResponse({ + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'degraded', + warnings: [ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + next_action: 'retry', + }, + ], + }, + }) + ) ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'token') - ).rejects.toThrow('Invalid response format from feed creation API'); + await result.current.convertFeed('https://example.com/articles', 'token'); }); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Invalid response format from feed creation API'); + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect((result.current.result as any)?.previewStatus).toBe('degraded'); + expect((result.current.result as any)?.warnings).toEqual([ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + nextAction: 'retry', + }, + ]); + }); }); - it('marks the feed as not-ready-yet when preview endpoint keeps returning 5xx', async () => { + it('rejects camelCase-only create payloads to enforce canonical snake_case contract', async () => { server.use( http.post('/api/v1/feeds', async () => HttpResponse.json( - buildFeedResponse({ - feed_token: 'generated-token', - public_url: '/api/v1/feeds/generated-token', - json_public_url: '/api/v1/feeds/generated-token.json', - }), + { + success: true, + data: { + feed: { + id: 'feed-1', + name: 'Example Feed', + url: 'https://example.com/articles', + feedToken: 'generated-token', + publicUrl: '/api/v1/feeds/generated-token', + jsonPublicUrl: '/api/v1/feeds/generated-token.json', + }, + conversion: { + readinessPhase: 'link_created', + previewStatus: 'pending', + warnings: [], + }, + }, + }, { status: 201 } ) - ), - http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(undefined, { status: 502 })) + ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'token'); + await expect(result.current.convertFeed('https://example.com/articles', 'token')).rejects.toMatchObject( + { + kind: 'server', + code: 'INVALID_RESPONSE', + } + ); }); - - expect(result.current.error).toBeUndefined(); - expect(result.current.result?.feed.feed_token).toBe('generated-token'); - await waitFor( - () => { - expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); - expect(result.current.result?.preview.items).toEqual([]); - expect(result.current.result?.preview.error).toBe( - 'Feed is still preparing. Try again in a few seconds.' - ); - expect(result.current.result?.preview.isLoading).toBe(false); - }, - { timeout: 6000 } - ); }); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index 7f55096d..dc305cd2 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -2,14 +2,15 @@ import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } fro import { renderHook, act, waitFor } from '@testing-library/preact'; import { useFeedConversion } from '../hooks/useFeedConversion'; -const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; -const SHORT_SETTLE_MS = 50; -const FULL_SETTLE_MS = 100; - -const sumDelays = (delays: readonly number[]) => delays.reduce((total, delay) => total + delay, 0); - -const advanceAfterRetries = async (delays: readonly number[], settleMs: number) => { - await vi.advanceTimersByTimeAsync(sumDelays(delays) + settleMs); +const mockFeed = { + id: 'feed-1', + name: 'Example Feed', + url: 'https://example.com/articles', + feed_token: 'feed-token-1', + public_url: '/api/v1/feeds/feed-token-1', + json_public_url: '/api/v1/feeds/feed-token-1.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', }; describe('useFeedConversion', () => { @@ -17,6 +18,8 @@ describe('useFeedConversion', () => { beforeEach(() => { vi.clearAllMocks(); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); fetchMock = vi.spyOn(globalThis, 'fetch'); }); @@ -24,7 +27,7 @@ describe('useFeedConversion', () => { fetchMock.mockRestore(); }); - it('should initialize with default state', () => { + it('initializes with empty state', () => { const { result } = renderHook(() => useFeedConversion()); expect(result.current.isConverting).toBe(false); @@ -32,519 +35,223 @@ describe('useFeedConversion', () => { expect(result.current.error).toBeUndefined(); }); - it('should handle successful conversion', async () => { - const mockFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: mockFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + it('creates a feed with a url-only payload and hydrates ready result metadata', async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, + }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) ) - ); - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview item', - content_text: 'Preview excerpt', - url: 'https://example.com/item', - date_published: '2024-01-02T00:00:00Z', + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], + }, }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) ) - ); + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [{ title: 'Preview item', content_text: 'Preview excerpt', date_published: '2024-01-02' }], + }), + { status: 200, headers: { 'Content-Type': 'application/feed+json' } } + ) + ); const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; - await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - expect(result.current.isConverting).toBe(false); - expect(conversionResult).toEqual({ - feed: mockFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, - }); + const createRequest = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(createRequest?.method).toBe('POST'); + expect(JSON.parse(String(createRequest?.body))).toEqual({ url: 'https://example.com/articles' }); + await waitFor(() => { - expect(result.current.result).toEqual({ - feed: mockFeed, - preview: { - items: [ - { - title: 'Preview item', - excerpt: 'Preview excerpt', - publishedLabel: 'Jan 2, 2024', - url: 'https://example.com/item', - }, - ], - error: undefined, - isLoading: false, - }, - readinessPhase: 'feed_ready', - retry: undefined, - }); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.previewStatus).toBe('ready'); + expect(result.current.result?.warnings).toEqual([]); + expect(result.current.result?.preview.items[0]?.title).toBe('Preview item'); }); - expect(result.current.error).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); }); - it('should handle conversion error', async () => { + it('returns structured auth failure metadata without text parsing', async () => { fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ success: false, - error: { message: 'Bad Request' }, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', + }, }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - } + { status: 401, headers: { 'Content-Type': 'application/json' } } ) ); const { result } = renderHook(() => useFeedConversion()); await act(async () => { - await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( - 'Bad Request' - ); + await expect( + result.current.convertFeed('https://example.com/articles', 'token-123') + ).rejects.toMatchObject({ + kind: 'auth', + code: 'UNAUTHORIZED', + nextAction: 'enter_token', + retryAction: 'none', + }); }); - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toContain('Bad Request'); - }); - - it('should handle network errors gracefully', async () => { - fetchMock.mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect(result.current.convertFeed('https://example.com', 'faraday', 'testtoken')).rejects.toThrow( - 'Network error' - ); + expect(result.current.error).toMatchObject({ + kind: 'auth', + code: 'UNAUTHORIZED', + nextAction: 'enter_token', + retryAction: 'none', + retryable: false, }); - - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Network error'); - }); - - it('preserves the created feed when preview loading fails after feed creation', async () => { - vi.useFakeTimers(); - try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValue(new Response('nope', { status: 502 })); - - const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; - - await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); - }); - - expect(result.current.isConverting).toBe(false); - expect(conversionResult).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, - }); - await waitFor(() => { - expect(result.current.result).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: 'Feed is still preparing. Try again in a few seconds.', - isLoading: false, - }, - readinessPhase: 'feed_not_ready_yet', - retry: undefined, - }); - }); - expect(result.current.error).toBeUndefined(); - } finally { - vi.useRealTimers(); - } }); - it('publishes link_created before readiness is confirmed', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - let resolvePreviewResponse: ((value: Response) => void) | undefined; - const previewResponse = new Promise((resolve) => { - resolvePreviewResponse = resolve; - }); - + it('stores retryable primary intent for double-failure create responses', async () => { fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ - success: true, - data: { feed: createdFeed }, + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Browserless also failed', + kind: 'server', + retryable: true, + next_action: 'retry', + retry_action: 'primary', + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 502, headers: { 'Content-Type': 'application/json' } } ) ); - fetchMock.mockReturnValueOnce(previewResponse as Promise); const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); - - expect(conversionResult).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, - }); - expect(result.current.isConverting).toBe(false); - expect(result.current.result).toEqual(conversionResult); - - resolvePreviewResponse?.( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview item', - content_text: 'Preview excerpt', - url: 'https://example.com/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); - - await waitFor(() => { - expect(result.current.result?.preview).toEqual({ - items: [ - { - title: 'Preview item', - excerpt: 'Preview excerpt', - publishedLabel: 'Jan 2, 2024', - url: 'https://example.com/item', - }, - ], - error: undefined, - isLoading: false, + await expect( + result.current.convertFeed('https://example.com/articles', 'token-123') + ).rejects.toMatchObject({ + kind: 'server', + retryable: true, + nextAction: 'retry', + retryAction: 'primary', }); - expect(result.current.result?.readinessPhase).toBe('feed_ready'); }); }); - it('retries readiness checks after transient preview failures and eventually becomes ready', async () => { - vi.useFakeTimers(); - try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: createdFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValueOnce(new Response('temporary-failure', { status: 500 })) - .mockResolvedValueOnce(new Response('still-warming-up', { status: 503 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - items: [ - { - title: 'Recovered item', - content_text: 'Recovered preview excerpt', - url: 'https://example.com/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS.slice(0, 2), SHORT_SETTLE_MS); - }); - - await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('feed_ready'); - expect(result.current.result?.preview.items[0]?.title).toBe('Recovered item'); - }); - expect(fetchMock).toHaveBeenCalledTimes(4); - } finally { - vi.useRealTimers(); - } - }); - - it('stops readiness retries after the configured limit and marks feed_not_ready_yet', async () => { - vi.useFakeTimers(); - try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { feed: createdFeed }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - .mockResolvedValue(new Response('temporary-failure', { status: 500 })); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); - }); - - await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); - expect(result.current.result?.preview.error).toBe( - 'Feed is still preparing. Try again in a few seconds.' - ); - }); - expect(fetchMock).toHaveBeenCalledTimes(6); - } finally { - vi.useRealTimers(); - } - }); - - it('marks preview_unavailable for non-retryable preview responses', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - + it('hydrates preview fetch success while preserving status warnings', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ success: true, - data: { feed: createdFeed }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, + }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'degraded', + warnings: [ + { + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + next_action: 'retry', + }, + ], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [], + }), + { status: 200, headers: { 'Content-Type': 'application/feed+json' } } + ) + ); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); await waitFor(() => { - expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); - expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); - }); - }); - - it('normalizes hostname-only input before creating a feed', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com/articles', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: true, - data: { - feed: createdFeed, - }, - }), + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.previewStatus).toBe('ready'); + expect(result.current.result?.warnings).toEqual([ { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - }) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await result.current.convertFeed('example.com/articles', 'faraday', 'testtoken'); - }); - - const firstRequest = fetchMock.mock.calls[0]?.[0] as Request; - expect(firstRequest instanceof Request ? firstRequest.url : String(firstRequest)).toContain( - '/api/v1/feeds' - ); - expect(await firstRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', - strategy: 'faraday', + code: 'preview_partial', + message: 'Preview content could not be fully verified.', + retryable: true, + nextAction: 'retry', + }, + ]); }); }); - it('automatically retries browserless after a faraday failure', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com/articles', - strategy: 'browserless', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - + it('merges degraded preview fetch warnings into the committed result', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ - success: false, - error: { message: 'Upstream timeout' }, + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } + { status: 201, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( @@ -552,124 +259,71 @@ describe('useFeedConversion', () => { JSON.stringify({ success: true, data: { - feed: createdFeed, + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + next_action: 'wait', + }, + ], + }, }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, + new Response('', { + status: 503, headers: { 'Content-Type': 'application/feed+json' }, }) ); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - const retryRequest = fetchMock.mock.calls[1]?.[0] as Request; - expect(await retryRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', - strategy: 'browserless', - }); - expect(result.current.result?.retry).toEqual({ - automatic: true, - from: 'faraday', - to: 'browserless', - }); await waitFor(() => { - expect(result.current.result?.preview.isLoading).toBe(false); - }); - }); - - it('does not auto-retry browserless for unauthorized faraday failures', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Unauthorized' }, - }), + expect(result.current.result?.previewStatus).toBe('degraded'); + expect(result.current.result?.warnings).toEqual([ { - status: 401, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') - ).rejects.toThrow('Unauthorized'); - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Unauthorized'); - }); - - it('does not auto-retry when API returns a non-retryable BAD_REQUEST code', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { code: 'BAD_REQUEST', message: 'Input rejected' }, - }), + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + nextAction: 'wait', + }, { - status: 400, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - await act(async () => { - await expect( - result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken') - ).rejects.toThrow('Input rejected'); + code: 'PREVIEW_HTTP_503', + message: 'Preview content is partially degraded right now.', + retryable: true, + nextAction: 'retry', + }, + ]); }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe('Input rejected'); }); - it('still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url', async () => { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com/articles', - strategy: 'browserless', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - + it('merges unavailable preview fetch warnings into the committed result', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ - success: false, - error: { - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to fetch https://example.com/articles', + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, }, }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } + { status: 201, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( @@ -677,206 +331,134 @@ describe('useFeedConversion', () => { JSON.stringify({ success: true, data: { - feed: createdFeed, + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + next_action: 'wait', + }, + ], + }, }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) .mockResolvedValueOnce( - new Response(JSON.stringify({ items: [] }), { - status: 200, + new Response('not found', { + status: 404, headers: { 'Content-Type': 'application/feed+json' }, }) ); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - const retryRequest = fetchMock.mock.calls[1]?.[0] as Request; - expect(await retryRequest.clone().json()).toEqual({ - url: 'https://example.com/articles', - strategy: 'browserless', - }); - expect(result.current.result?.retry).toEqual({ - automatic: true, - from: 'faraday', - to: 'browserless', + await waitFor(() => { + expect(result.current.result?.previewStatus).toBe('unavailable'); + expect(result.current.result?.warnings).toEqual([ + { + code: 'STATUS_WARNING', + message: 'Status warning should be preserved.', + retryable: false, + nextAction: 'wait', + }, + { + code: 'PREVIEW_HTTP_404', + message: 'Preview unavailable right now.', + retryable: false, + nextAction: 'wait', + }, + ]); }); }); - it('does not offer a duplicate manual retry after automatic fallback also fails', async () => { + it('retries readiness checks from the current result token', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ - success: false, - error: { message: 'Upstream timeout' }, + success: true, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [], + }, + }, }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } + { status: 201, headers: { 'Content-Type': 'application/json' } } ) ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: { message: 'Browserless also failed' }, - }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - - const { result } = renderHook(() => useFeedConversion()); - - let thrownError: (Error & { manualRetryStrategy?: string }) | undefined; - await act(async () => { - try { - await result.current.convertFeed('https://example.com/articles', 'faraday', 'testtoken'); - } catch (error) { - thrownError = error as Error & { manualRetryStrategy?: string }; - } - }); - - expect(thrownError?.message).toBe( - 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' - ); - expect(thrownError?.manualRetryStrategy).toBeUndefined(); - expect(result.current.result).toBeUndefined(); - expect(result.current.error).toBe( - 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' - ); - }); - - it('ignores stale preview updates from an earlier conversion request', async () => { - const feedA = { - id: 'feed-a-id', - name: 'Feed A', - url: 'https://example.com/a', - strategy: 'faraday', - feed_token: 'feed-a-token', - public_url: 'https://example.com/feed-a', - json_public_url: 'https://example.com/feed-a.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - const feedB = { - id: 'feed-b-id', - name: 'Feed B', - url: 'https://example.com/b', - strategy: 'faraday', - feed_token: 'feed-b-token', - public_url: 'https://example.com/feed-b', - json_public_url: 'https://example.com/feed-b.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - let resolvePreviewA: ((value: Response) => void) | undefined; - const previewAPromise = new Promise((resolve) => { - resolvePreviewA = resolve; - }); - let resolvePreviewB: ((value: Response) => void) | undefined; - const previewBPromise = new Promise((resolve) => { - resolvePreviewB = resolve; - }); - - fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ success: true, - data: { feed: feedA }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_not_ready_yet', + preview_status: 'pending', + warnings: [ + { + code: 'PREVIEW_NOT_READY', + message: 'Feed is still preparing.', + retryable: true, + next_action: 'wait', + }, + ], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) - .mockReturnValueOnce(previewAPromise as Promise) .mockResolvedValueOnce( new Response( JSON.stringify({ success: true, - data: { feed: feedB }, + data: { + feed: mockFeed, + conversion: { + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [], + }, + }, }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } + { status: 200, headers: { 'Content-Type': 'application/json' } } ) ) - .mockReturnValueOnce(previewBPromise as Promise); + .mockResolvedValueOnce( + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + }) + ); const { result } = renderHook(() => useFeedConversion()); - await act(async () => { - await result.current.convertFeed('https://example.com/a', 'faraday', 'testtoken'); + await result.current.convertFeed('https://example.com/articles', 'token-123'); }); - await act(async () => { - await result.current.convertFeed('https://example.com/b', 'faraday', 'testtoken'); - }); - - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - - resolvePreviewB?.( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview B', - content_text: 'Current preview item', - url: 'https://example.com/b/item', - date_published: '2024-01-02T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); await waitFor(() => { - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); + expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); }); - resolvePreviewA?.( - new Response( - JSON.stringify({ - items: [ - { - title: 'Preview A', - content_text: 'Stale preview item', - url: 'https://example.com/a/item', - date_published: '2024-01-03T00:00:00Z', - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/feed+json' }, - } - ) - ); + await act(async () => { + result.current.retryReadinessCheck(); + }); await waitFor(() => { - expect(result.current.result?.feed.feed_token).toBe('feed-b-token'); - expect(result.current.result?.preview.items[0]?.title).toBe('Preview B'); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); }); }); }); diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index d4fed873..13a9e984 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -1,7 +1,21 @@ -import type { CreateFeedResponses, GetApiMetadataResponses, ListStrategiesResponses } from './generated'; +import type { GetApiMetadataResponses } from './generated'; + +export interface FeedRecord { + id: string; + name: string; + url: string; + feed_token: string; + public_url: string; + json_public_url: string; + created_at: string; + updated_at: string; +} + +export type FeedReadinessPhase = 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; +export type FeedPreviewStatus = 'pending' | 'ready' | 'degraded' | 'unavailable'; +export type FeedRetryAction = 'alternate' | 'primary' | 'none'; +export type FeedNextAction = 'enter_token' | 'correct_input' | 'retry' | 'wait' | 'none'; -export type FeedRecord = CreateFeedResponses[201]['data']['feed']; -export type StrategyRecord = ListStrategiesResponses[200]['data']['strategies'][number]; export interface FeedPreviewItem { title: string; excerpt: string; @@ -9,27 +23,49 @@ export interface FeedPreviewItem { url?: string; } +export interface FeedStatusWarning { + code: string; + message: string; + retryable: boolean; + nextAction: FeedNextAction; +} + export interface FeedPreviewState { items: FeedPreviewItem[]; - error?: string; isLoading: boolean; } -export type FeedReadinessPhase = 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; - export interface FeedRetryState { automatic: boolean; - from: string; - to: string; +} + +export interface FeedConversionState { + readinessPhase: FeedReadinessPhase; + previewStatus: FeedPreviewStatus; + warnings: FeedStatusWarning[]; + retry?: FeedRetryState; } export interface CreatedFeedResult { feed: FeedRecord; preview: FeedPreviewState; readinessPhase: FeedReadinessPhase; + previewStatus: FeedPreviewStatus; + warnings: FeedStatusWarning[]; retry?: FeedRetryState; } +export interface FeedCreationError { + kind: 'auth' | 'input' | 'network' | 'server'; + code: string; + retryable: boolean; + nextAction: FeedNextAction; + retryAction: FeedRetryAction; + nextStrategy?: string; + message: string; + status?: number; +} + export interface ApiMetadataRecord { api: GetApiMetadataResponses[200]['data']['api']; instance: { diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 2494ebbd..f589a710 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; +export { createFeed, getApiMetadata, getFeedStatus, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusResponse, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 2d508010..38707d19 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetFeedStatusData, GetFeedStatusResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; export type Options = Options2 & { /** @@ -47,6 +47,13 @@ export const createFeed = (options: Option */ export const renderFeedByToken = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}', ...options }); +/** + * Get feed status + * + * Get feed status + */ +export const getFeedStatus = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}/status', ...options }); + /** * Authenticated health check * diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 88d58560..0074e863 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -42,7 +42,6 @@ export type GetApiMetadataResponse = GetApiMetadataResponses[keyof GetApiMetadat export type CreateFeedData = { body?: { - strategy: string; url: string; }; headers: { @@ -60,7 +59,11 @@ export type CreateFeedErrors = { 401: { error: { code: string; + kind: string; message: string; + next_action: string; + retry_action: string; + retryable: boolean; }; success: boolean; }; @@ -70,7 +73,11 @@ export type CreateFeedErrors = { 403: { error: { code: string; + kind: string; message: string; + next_action: string; + retry_action: string; + retryable: boolean; }; success: boolean; }; @@ -84,6 +91,11 @@ export type CreateFeedResponses = { */ 201: { data: { + conversion: { + preview_status: string; + readiness_phase: string; + warnings: Array; + }; feed: { created_at: string; feed_token: string; @@ -91,7 +103,6 @@ export type CreateFeedResponses = { json_public_url: string; name: string; public_url: string; - strategy: string; updated_at: string; url: string; }; @@ -140,6 +151,40 @@ export type RenderFeedByTokenResponses = { export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses]; +export type GetFeedStatusData = { + body?: never; + path: { + token: string; + }; + query?: never; + url: '/feeds/{token}/status'; +}; + +export type GetFeedStatusResponses = { + /** + * Returns readiness and degradation metadata for a generated feed. + */ + 200: { + data: { + conversion: { + preview_status: string; + readiness_phase: string; + warnings: Array; + }; + feed: { + feed_token: string; + json_public_url: string; + name: string; + public_url: string; + url: string; + }; + }; + success: boolean; + }; +}; + +export type GetFeedStatusResponse = GetFeedStatusResponses[keyof GetFeedStatusResponses]; + export type GetHealthStatusData = { body?: never; headers: { @@ -157,7 +202,11 @@ export type GetHealthStatusErrors = { 401: { error: { code: string; + kind: string; message: string; + next_action: string; + retry_action: string; + retryable: boolean; }; success: boolean; }; @@ -167,7 +216,11 @@ export type GetHealthStatusErrors = { 500: { error: { code: string; + kind: string; message: string; + next_action: string; + retry_action: string; + retryable: boolean; }; success: boolean; }; diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index b1adfcaf..84f41582 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,65 +1,75 @@ import { useEffect, useRef, useState } from 'preact/hooks'; +import type { JSX } from 'preact'; import { ResultDisplay } from './ResultDisplay'; -import { CreateFeedPanel, UtilityStrip, type Strategy } from './AppPanels'; +import { CreateFeedPanel, UtilityStrip } from './AppPanels'; import { useAccessToken } from '../hooks/useAccessToken'; import { useApiMetadata } from '../hooks/useApiMetadata'; import { useFeedConversion } from '../hooks/useFeedConversion'; -import { useStrategies } from '../hooks/useStrategies'; +import { useAppRoute } from '../routes/appRoute'; +import { + clearFeedDraftState, + loadFeedDraftState, + loadFeedResultState, + saveFeedDraftState, +} from '../utils/feedWorkflowStorage'; import { normalizeUserUrl } from '../utils/url'; +import type { WorkflowState } from './AppPanels'; +import type { CreatedFeedResult, FeedCreationError } from '../api/contracts'; const EMPTY_FEED_ERRORS = { url: '', form: '' }; const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; -const preferredStrategy = (strategies: { id: string }[]) => - strategies.find((strategy) => strategy.id === 'faraday')?.id ?? strategies[0]?.id; -function strategyHint(strategy: Strategy) { - if (strategy.id === 'faraday') return 'Best for most pages.'; - if (strategy.id === 'browserless') return 'Use when the page needs JavaScript to load content.'; - return strategy.name; -} - -function isAccessTokenError(message: string) { - const normalized = message.toLowerCase(); - const mentionsAuthToken = - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bearer'); - - return ( - normalized.includes('unauthorized') || - normalized.includes('invalid token') || - normalized.includes('token rejected') || - normalized.includes('authentication') || - (normalized.includes('forbidden') && mentionsAuthToken) - ); -} +function deriveWorkflowState({ + resultReadinessPhase, + conversionError, + feedFieldErrors, + isConverting, + missingResultRoute, + routeKind, + tokenError, + tokenStateError, + metadataError, +}: { + resultReadinessPhase?: CreatedFeedResult['readinessPhase']; + conversionError?: FeedCreationError; + feedFieldErrors: { url: string; form: string }; + isConverting: boolean; + missingResultRoute: boolean; + routeKind: string; + tokenError: string; + tokenStateError?: string; + metadataError?: string; +}): WorkflowState { + if (missingResultRoute || tokenStateError || metadataError) return 'failed'; + if (routeKind === 'token' || tokenError) return 'token_required'; + if (conversionError?.nextAction === 'enter_token' || conversionError?.kind === 'auth') + return 'token_required'; + if (resultReadinessPhase === 'feed_ready') return 'ready'; + if (resultReadinessPhase === 'link_created' || resultReadinessPhase === 'feed_not_ready_yet') { + return 'warming'; + } + if (resultReadinessPhase === 'preview_unavailable') return 'failed'; + if (isConverting) return 'submitting'; + if (feedFieldErrors.url || feedFieldErrors.form || conversionError?.nextAction === 'correct_input') { + return 'validating'; + } -function isActionableStrategySwitch(message: string, currentStrategy: string, retryStrategy: string) { - if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; - - const normalized = message.toLowerCase(); - return !( - normalized.includes('unauthorized') || - normalized.includes('forbidden') || - normalized.includes('not allowed') || - normalized.includes('disabled') || - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bad request') || - normalized.includes('url') || - normalized.includes('unsupported strategy') - ); -} + if (conversionError) return 'failed'; -interface ConversionErrorWithMeta extends Error { - manualRetryStrategy?: string; + return 'idle'; } -function BrandLockup() { +function BrandLockup({ onNavigateHome }: { onNavigateHome: () => void }) { return ( - + { + event.preventDefault(); + onNavigateHome(); + }} + >
- + navigate({ kind: 'create' })} />
- {showReadinessAction && ( + {canManuallyRetryReadiness && (
- {result.retry && ( -

- {`Retried automatically with ${result.retry.to} after ${result.retry.from} could not finish the page.`} -

- )} {isFeedReady && ( <> - + Open feed @@ -141,7 +160,7 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res
)} - {isFeedReady && preview.items.length > 0 && ( + {!preview.isLoading && preview.items.length > 0 && (

Preview

@@ -177,28 +196,24 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res
)} - {isFeedReady && !preview.isLoading && preview.items.length === 0 && !preview.error && ( + {!preview.isLoading && preview.items.length === 0 && previewMessage && (

Preview

Latest items from this feed

-

- Feed is ready. Preview items will appear once the source publishes entries. +

+ {previewMessage}

)} - {!preview.isLoading && preview.error && ( -
-
-

Preview

-

Latest items from this feed

-
-

{preview.error}

-
- )} - {copyNotice && (

{copyNotice}

diff --git a/frontend/src/hooks/useAccessToken.ts b/frontend/src/hooks/useAccessToken.ts index c55724ed..21449fa9 100644 --- a/frontend/src/hooks/useAccessToken.ts +++ b/frontend/src/hooks/useAccessToken.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'preact/hooks'; const ACCESS_TOKEN_KEY = 'html2rss_access_token'; +let inMemoryToken = ''; interface AccessTokenState { token?: string; @@ -8,46 +9,28 @@ interface AccessTokenState { error?: string; } -const memoryStorage = (() => { - const store = new Map(); - - return { - get length() { - return store.size; - }, - clear: () => store.clear(), - getItem: (key: string) => store.get(key), - key: (index: number) => [...store.keys()][index], - removeItem: (key: string) => { - store.delete(key); - }, - setItem: (key: string, value: string) => { - store.set(key, value); - }, - } as Storage; -})(); - -const resolveStorage = (): Storage => { - if (globalThis.window === undefined) return memoryStorage; +const readSessionToken = (): string => { + if (globalThis.window === undefined) return inMemoryToken; try { - return globalThis.localStorage ?? globalThis.sessionStorage ?? memoryStorage; + return globalThis.sessionStorage?.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ''; } catch { - try { - return globalThis.sessionStorage ?? memoryStorage; - } catch { - return memoryStorage; - } + return inMemoryToken; } }; -const clearLegacySessionToken = () => { +const writeSessionToken = (token: string) => { + inMemoryToken = token; if (globalThis.window === undefined) return; try { - globalThis.sessionStorage?.removeItem(ACCESS_TOKEN_KEY); + if (token) { + globalThis.sessionStorage?.setItem(ACCESS_TOKEN_KEY, token); + } else { + globalThis.sessionStorage?.removeItem(ACCESS_TOKEN_KEY); + } } catch { - // Ignore restricted sessionStorage access (privacy mode, sandboxed contexts). + // Keep in-memory fallback only when sessionStorage is unavailable. } }; @@ -57,27 +40,11 @@ export function useAccessToken() { }); useEffect(() => { - const storage = resolveStorage(); - try { - const token = storage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ''; - let legacyToken = ''; - if (!token && globalThis.window !== undefined) { - try { - legacyToken = globalThis.sessionStorage?.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ''; - } catch { - // Treat restricted sessionStorage access as no legacy token. - legacyToken = ''; - } - } - - if (!token && legacyToken) { - storage.setItem(ACCESS_TOKEN_KEY, legacyToken); - clearLegacySessionToken(); - } + const token = readSessionToken(); setState({ - token: token || legacyToken || undefined, + token: token || undefined, isLoading: false, }); } catch { @@ -92,9 +59,7 @@ export function useAccessToken() { const normalized = token.trim(); if (!normalized) throw new Error('Access token is required'); - const storage = resolveStorage(); - storage.setItem(ACCESS_TOKEN_KEY, normalized); - clearLegacySessionToken(); + writeSessionToken(normalized); setState({ token: normalized, @@ -103,9 +68,7 @@ export function useAccessToken() { }; const clearToken = () => { - const storage = resolveStorage(); - storage.removeItem(ACCESS_TOKEN_KEY); - clearLegacySessionToken(); + writeSessionToken(''); setState({ isLoading: false, diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts deleted file mode 100644 index b613a949..00000000 --- a/frontend/src/hooks/useAuth.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { useState, useEffect } from 'preact/hooks'; - -const USERNAME_KEY = 'html2rss_username'; -const TOKEN_KEY = 'html2rss_token'; - -interface AuthState { - isAuthenticated: boolean; - username?: string; - token?: string; - isLoading: boolean; - error?: string; -} - -const memoryStorage: Storage = (() => { - const store = new Map(); - - return { - get length() { - return store.size; - }, - clear: () => store.clear(), - getItem: (key: string) => store.get(key), - key: (index: number) => [...store.keys()][index], - removeItem: (key: string) => { - store.delete(key); - }, - setItem: (key: string, value: string) => { - store.set(key, value); - }, - } as Storage; -})(); - -const resolveStorage = (): Storage => { - if (globalThis.window === undefined) { - return memoryStorage; - } - - try { - return globalThis.localStorage ?? globalThis.sessionStorage ?? memoryStorage; - } catch { - try { - return globalThis.sessionStorage ?? memoryStorage; - } catch { - return memoryStorage; - } - } -}; - -export function useAuth() { - const [authState, setAuthState] = useState({ - isAuthenticated: false, - isLoading: true, - }); - - useEffect(() => { - const storage = resolveStorage(); - - try { - const username = storage.getItem(USERNAME_KEY); - const token = storage.getItem(TOKEN_KEY); - - if (username && token && username.trim() && token.trim()) { - setAuthState({ - isAuthenticated: true, - username: username.trim(), - token: token.trim(), - isLoading: false, - }); - } else { - setAuthState((previous) => ({ ...previous, isLoading: false })); - } - } catch { - setAuthState((previous) => ({ - ...previous, - isLoading: false, - error: 'Failed to load authentication state', - })); - } - }, []); - - const login = async (username: string, token: string) => { - if (!username?.trim()) { - throw new Error('Username is required'); - } - if (!token?.trim()) { - throw new Error('Token is required'); - } - - const storage = resolveStorage(); - - try { - storage.setItem(USERNAME_KEY, username.trim()); - storage.setItem(TOKEN_KEY, token.trim()); - - setAuthState({ - isAuthenticated: true, - username: username.trim(), - token: token.trim(), - isLoading: false, - }); - } catch { - throw new Error('Failed to save authentication data'); - } - }; - - const logout = () => { - const storage = resolveStorage(); - - try { - storage.removeItem(USERNAME_KEY); - storage.removeItem(TOKEN_KEY); - - setAuthState({ - isAuthenticated: false, - isLoading: false, - }); - } catch { - setAuthState((previous) => ({ - ...previous, - error: 'Failed to clear authentication data', - })); - } - }; - - return { - isAuthenticated: authState.isAuthenticated, - username: authState.username, - token: authState.token, - isLoading: authState.isLoading, - error: authState.error, - login, - logout, - }; -} diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index 482003ee..c2a2738d 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -1,96 +1,144 @@ -import { useRef, useState } from 'preact/hooks'; -import { createFeed } from '../api/generated'; -import { apiClient } from '../api/client'; -import type { CreatedFeedResult, FeedPreviewItem, FeedReadinessPhase, FeedRecord } from '../api/contracts'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import type { + CreatedFeedResult, + FeedConversionState, + FeedCreationError, + FeedNextAction, + FeedPreviewItem, + FeedPreviewStatus, + FeedRecord, + FeedReadinessPhase, + FeedRetryAction, + FeedRetryState, + FeedStatusWarning, +} from '../api/contracts'; +import { saveFeedResultSnapshot } from '../utils/feedWorkflowStorage'; import { normalizeUserUrl } from '../utils/url'; -interface JsonFeedItem { - title?: string; - content_text?: string; - content_html?: string; - url?: string; - external_url?: string; - date_published?: string; +interface ConversionState { + isConverting: boolean; + result?: CreatedFeedResult; + error?: FeedCreationError; +} + +interface RawFeedRecord { + id?: unknown; + name?: unknown; + url?: unknown; + feed_token?: unknown; + public_url?: unknown; + json_public_url?: unknown; + created_at?: unknown; + updated_at?: unknown; +} + +interface RawFeedConversionState { + readiness_phase?: unknown; + preview_status?: unknown; + warnings?: unknown; + retry?: unknown; +} + +interface RawFeedPayload { + feed?: RawFeedRecord; + conversion?: RawFeedConversionState; +} + +interface RawApiResponse { + success?: unknown; + data?: RawFeedPayload; + error?: unknown; +} + +interface RawErrorEnvelope { + kind?: unknown; + code?: unknown; + retryable?: unknown; + next_action?: unknown; + retry_action?: unknown; + next_strategy?: unknown; + message?: unknown; } interface JsonFeedResponse { - items?: JsonFeedItem[]; + items?: unknown[]; } -interface ConversionState { - isConverting: boolean; - result?: CreatedFeedResult; - error?: string; +interface PreviewLoadResult { + items: FeedPreviewItem[]; + warnings: FeedStatusWarning[]; + previewStatus: FeedPreviewStatus; } -interface ConversionError extends Error { - manualRetryStrategy?: string; +interface FeedStatusLoadResult { + conversion: FeedConversionState; + shouldRetry: boolean; } +type ResultProgressState = 'none' | 'initial' | 'status' | 'preview'; + +const STATUS_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; -const FEED_NOT_READY_MESSAGE = 'Feed is still preparing. Try again in a few seconds.'; -const NON_RETRYABLE_ERROR_CODES = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN']); -const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; +const PREVIEW_DEGRADED_MESSAGE = 'Preview content is partially degraded right now.'; export function useFeedConversion() { const requestIdReference = useRef(0); - const [state, setState] = useState({ - isConverting: false, - }); + const hydrationAbortControllerReference = useRef(undefined); + const resultProgressReference = useRef('none'); + const [state, setState] = useState({ isConverting: false }); - const convertFeed = async (url: string, strategy: string, token: string) => { - const normalizedUrl = normalizeUserUrl(url); - const requestedStrategy = strategy.trim(); - const fallbackStrategy = requestedStrategy === 'faraday' ? 'browserless' : undefined; + const cancelHydration = () => { + hydrationAbortControllerReference.current?.abort(); + hydrationAbortControllerReference.current = undefined; + }; - if (!normalizedUrl) throw new Error('URL is required'); - if (!requestedStrategy) throw new Error('Strategy is required'); + useEffect( + () => () => { + requestIdReference.current += 1; + cancelHydration(); + }, + [] + ); - if (!isValidHttpUrl(normalizedUrl)) { - throw new Error('Invalid URL format'); - } + async function convertFeed(url: string, token: string) { + const normalizedUrl = normalizeUserUrl(url); + + if (!normalizedUrl) throw buildLocalError('Source URL is required.', 'input', 'correct_input'); + if (!isValidHttpUrl(normalizedUrl)) + throw buildLocalError('Invalid URL format.', 'input', 'correct_input'); const requestId = requestIdReference.current + 1; requestIdReference.current = requestId; - markConversionStarted(setState); + resultProgressReference.current = 'none'; + cancelHydration(); + setState((previous) => ({ ...previous, isConverting: true, error: undefined })); try { - const feed = await requestFeedCreation(normalizedUrl, requestedStrategy, token); - return publishCreatedFeed(feed, undefined, requestId, setState, requestIdReference); - } catch (firstError) { - if (shouldAutoRetry(requestedStrategy, fallbackStrategy, firstError)) { - try { - const feed = await requestFeedCreation(normalizedUrl, fallbackStrategy, token); - return publishCreatedFeed( - feed, - { automatic: true, from: requestedStrategy, to: fallbackStrategy }, - requestId, - setState, - requestIdReference - ); - } catch (secondError) { - const message = buildRetryFailureMessage( - firstError, - secondError, - requestedStrategy, - fallbackStrategy - ); - failConversion(setState, message, { manualRetryStrategy: undefined }); - } - } - - const message = toErrorMessage(firstError); - failConversion(setState, message, { manualRetryStrategy: alternateStrategy(requestedStrategy) }); + const createdFeed = await requestFeedCreation(normalizedUrl, token); + const result = buildCreatedFeedResult(createdFeed.feed, createdFeed.conversion); + publishCreatedFeed(result, requestId, setState, requestIdReference, resultProgressReference); + void hydrateFeedStatus( + createdFeed.feed, + requestId, + setState, + requestIdReference, + hydrationAbortControllerReference, + resultProgressReference + ); + return result; + } catch (error) { + const structuredError = normalizeFeedCreationError(error); + failConversion(setState, structuredError); + throw structuredError; } - }; + } const clearResult = () => { - globalThis.document.body.scrollIntoView({ behavior: 'smooth', block: 'start' }); + globalThis.document?.body?.scrollIntoView({ behavior: 'smooth', block: 'start' }); requestIdReference.current += 1; - - setState({ - isConverting: false, - }); + resultProgressReference.current = 'none'; + cancelHydration(); + setState({ isConverting: false }); }; const clearError = () => { @@ -103,11 +151,15 @@ export function useFeedConversion() { const requestId = requestIdReference.current + 1; requestIdReference.current = requestId; + resultProgressReference.current = 'status'; + cancelHydration(); const resetResult: CreatedFeedResult = { ...currentResult, - readinessPhase: 'link_created', - preview: buildLoadingPreviewState(), + preview: { + items: [], + isLoading: true, + }, }; setState((previous) => ({ @@ -116,7 +168,39 @@ export function useFeedConversion() { error: undefined, result: resetResult, })); - void hydratePreview(currentResult.feed, requestId, currentResult.retry, setState, requestIdReference); + saveFeedResultSnapshot(resetResult); + void hydrateFeedStatus( + currentResult.feed, + requestId, + setState, + requestIdReference, + hydrationAbortControllerReference, + resultProgressReference + ); + }; + + const restoreResult = (result: CreatedFeedResult) => { + resultProgressReference.current = progressFromResult(result); + setState((previous) => ({ + ...previous, + isConverting: false, + error: undefined, + result, + })); + + if (!result.preview.isLoading && result.previewStatus !== 'pending') return; + + const requestId = requestIdReference.current + 1; + requestIdReference.current = requestId; + cancelHydration(); + void hydrateFeedStatus( + result.feed, + requestId, + setState, + requestIdReference, + hydrationAbortControllerReference, + resultProgressReference + ); }; return { @@ -127,423 +211,842 @@ export function useFeedConversion() { clearError, clearResult, retryReadinessCheck, + restoreResult, }; } -interface PreviewLoadResult { - preview: CreatedFeedResult['preview']; - readinessPhase: FeedReadinessPhase; - shouldRetry: boolean; -} +async function requestFeedCreation( + url: string, + token: string +): Promise<{ feed: FeedRecord; conversion: FeedConversionState }> { + const response = await globalThis.fetch(resolveApiUrl('feeds'), { + method: 'POST', + headers: buildCreateHeaders(token), + body: JSON.stringify({ url }), + }); -async function loadPreview(feed: FeedRecord): Promise { - let response: Response; - try { - response = await globalThis.fetch(feed.json_public_url, { - headers: { Accept: 'application/feed+json' }, - }); - } catch { - return { - preview: { - items: [], - error: FEED_NOT_READY_MESSAGE, - isLoading: false, - }, - readinessPhase: 'feed_not_ready_yet', - shouldRetry: true, - }; - } + const payload = await readJsonResponse(response); if (!response.ok) { - if (isTransientReadinessStatus(response.status)) { - return { - preview: { - items: [], - error: FEED_NOT_READY_MESSAGE, - isLoading: false, - }, - readinessPhase: 'feed_not_ready_yet', - shouldRetry: true, - }; - } - - return { - preview: { - items: [], - error: PREVIEW_UNAVAILABLE_MESSAGE, - isLoading: false, - }, - readinessPhase: 'preview_unavailable', - shouldRetry: false, - }; + throw normalizeFeedCreationErrorFromResponse(response.status, payload?.error, payload); } - try { - const payload = (await response.json()) as JsonFeedResponse; - const items = - payload.items - ?.map((item) => normalizePreviewItem(item)) - .filter((item): item is FeedPreviewItem => item !== undefined) - .slice(0, 5) || []; - - return { - preview: { - items, - error: undefined, - isLoading: false, - }, - readinessPhase: 'feed_ready', - shouldRetry: false, - }; - } catch { - return { - preview: { - items: [], - error: PREVIEW_UNAVAILABLE_MESSAGE, - isLoading: false, - }, - readinessPhase: 'preview_unavailable', - shouldRetry: false, - }; + const feed = normalizeFeedRecord(payload?.data?.feed); + if (!feed) { + throw buildStructuredError( + 'server', + 'INVALID_RESPONSE', + true, + 'retry', + 'primary', + 'Unable to start feed generation.', + response.status + ); } -} -function buildLoadingPreviewState(): CreatedFeedResult['preview'] { return { - items: [], - error: undefined, - isLoading: true, + feed, + conversion: normalizeConversionState(payload?.data?.conversion), }; } -async function hydratePreview( +async function hydrateFeedStatus( feed: FeedRecord, requestId: number, - retry: CreatedFeedResult['retry'], setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, - requestIdReference: { current: number } + requestIdReference: { current: number }, + hydrationAbortControllerReference: { current: AbortController | undefined }, + resultProgressReference: { current: 'none' | 'initial' | 'status' | 'preview' } ) { - const delays = [0, ...PREVIEW_RETRY_DELAYS_MS]; - let lastAttempt: PreviewLoadResult | undefined; + hydrationAbortControllerReference.current?.abort(); + const controller = new AbortController(); + hydrationAbortControllerReference.current = controller; + + const delays = [0, ...STATUS_RETRY_DELAYS_MS]; + let latestConversion: FeedConversionState | undefined; + + try { + for (const [index, delayMs] of delays.entries()) { + if (delayMs > 0) await wait(delayMs, controller.signal); + if (requestIdReference.current !== requestId) return; - for (const [index, delayMs] of delays.entries()) { - if (delayMs > 0) await wait(delayMs); - if (requestIdReference.current !== requestId) return; + const statusResult = await requestFeedStatus(feed.feed_token, controller.signal); + latestConversion = statusResult.conversion; + if (requestIdReference.current !== requestId) return; - const attempt = await loadPreview(feed); - lastAttempt = attempt; - if (requestIdReference.current !== requestId) return; + const shouldKeepPreviewLoading = + statusResult.conversion.previewStatus === 'pending' || + statusResult.conversion.previewStatus === 'ready' || + statusResult.conversion.previewStatus === 'degraded'; - const exhausted = index === delays.length - 1; - if (!attempt.shouldRetry || exhausted) { - setPreviewResult( + commitResult( feed, - attempt.preview, - attempt.readinessPhase, - retry, + statusResult.conversion, + [], + shouldKeepPreviewLoading, requestId, setState, - requestIdReference + requestIdReference, + resultProgressReference ); + + const exhausted = index === delays.length - 1; + if (!statusResult.shouldRetry || exhausted) break; + } + + if (!latestConversion) return; + + const resolvedConversion = latestConversion; + + if (shouldLoadPreviewItems(resolvedConversion.previewStatus)) { + void loadPreviewItems(feed.json_public_url, controller.signal) + .then((previewResult) => { + if (requestIdReference.current !== requestId) return; + + commitResult( + feed, + mergePreviewResult(resolvedConversion, previewResult), + previewResult.items, + false, + requestId, + setState, + requestIdReference, + resultProgressReference + ); + }) + .catch((error) => { + if (isAbortError(error) || requestIdReference.current !== requestId) return; + + commitResult( + feed, + resolvedConversion, + [], + false, + requestId, + setState, + requestIdReference, + resultProgressReference + ); + }); return; } - } - if (!lastAttempt) { - setPreviewResult( + commitResult( + feed, + resolvedConversion, + [], + false, + requestId, + setState, + requestIdReference, + resultProgressReference + ); + } catch (error) { + if (isAbortError(error)) return; + + const fallbackConversion = latestConversion ?? buildPendingConversionState(); + const warning = buildFallbackWarning(error); + commitResult( feed, { - items: [], - error: FEED_NOT_READY_MESSAGE, - isLoading: false, + ...fallbackConversion, + previewStatus: warning.retryable ? 'degraded' : 'unavailable', + warnings: [...fallbackConversion.warnings, warning], }, - 'feed_not_ready_yet', - retry, + [], + false, requestId, setState, - requestIdReference + requestIdReference, + resultProgressReference ); + } finally { + if (hydrationAbortControllerReference.current === controller) { + hydrationAbortControllerReference.current = undefined; + } } } -async function requestFeedCreation(url: string, strategy: string, token: string): Promise { - const response = await createFeed({ - client: apiClient, - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - url, - strategy, - }, - throwOnError: true, +async function requestFeedStatus(feedToken: string, signal?: AbortSignal): Promise { + const response = await globalThis.fetch(resolveApiUrl(`feeds/${encodeURIComponent(feedToken)}/status`), { + headers: { Accept: 'application/json' }, + signal, }); - if (!response.data?.success || !response.data.data?.feed) { - throw new Error('Invalid response format'); + const payload = await readJsonResponse(response); + + if (!response.ok) { + if (isTransientHttpStatus(response.status)) { + return { + conversion: buildPendingConversionState(), + shouldRetry: true, + }; + } + + throw normalizeFeedCreationErrorFromResponse(response.status, payload?.error, payload); } - return response.data.data.feed; + const conversion = normalizeConversionState(payload?.data?.conversion); + return { + conversion, + shouldRetry: + conversion.readinessPhase === 'link_created' || + conversion.readinessPhase === 'feed_not_ready_yet' || + conversion.previewStatus === 'pending', + }; } -function isValidHttpUrl(value: string): boolean { +async function loadPreviewItems(previewUrl: string, signal?: AbortSignal): Promise { + let response: Response; + try { - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } -} + response = await globalThis.fetch(previewUrl, { + headers: { Accept: 'application/feed+json' }, + signal, + }); + } catch (error) { + if (isAbortError(error)) throw error; -function alternateStrategy(strategy: string): string | undefined { - if (strategy === 'faraday') return 'browserless'; - if (strategy === 'browserless') return 'faraday'; - return undefined; -} + return { + items: [], + warnings: [buildPreviewWarning('PREVIEW_NETWORK_ERROR', PREVIEW_UNAVAILABLE_MESSAGE, true, 'retry')], + previewStatus: 'degraded', + }; + } -function shouldAutoRetry( - strategy: string, - fallbackStrategy: string | undefined, - error: unknown -): fallbackStrategy is string { - if (strategy !== 'faraday' || !fallbackStrategy) return false; - return retryableForFallback(error); -} + if (!response.ok) { + if (isTransientHttpStatus(response.status)) { + return { + items: [], + warnings: [ + buildPreviewWarning(`PREVIEW_HTTP_${response.status}`, PREVIEW_DEGRADED_MESSAGE, true, 'retry'), + ], + previewStatus: 'degraded', + }; + } -function buildRetryFailureMessage( - firstError: unknown, - secondError: unknown, - requestedStrategy: string, - fallbackStrategy: string -): string { - const secondMessage = toErrorMessage(secondError); - const firstMessage = toErrorMessage(firstError); + return { + items: [], + warnings: [ + buildPreviewWarning(`PREVIEW_HTTP_${response.status}`, PREVIEW_UNAVAILABLE_MESSAGE, false, 'wait'), + ], + previewStatus: 'unavailable', + }; + } - if (firstMessage === secondMessage) { - return `Tried ${requestedStrategy} first, then ${fallbackStrategy}. ${secondMessage}`; + try { + const payload = (await response.json()) as JsonFeedResponse; + return { + items: normalizePreviewItems(payload.items), + warnings: [], + previewStatus: 'ready', + }; + } catch { + return { + items: [], + warnings: [buildPreviewWarning('PREVIEW_INVALID_JSON', PREVIEW_UNAVAILABLE_MESSAGE, false, 'wait')], + previewStatus: 'unavailable', + }; } +} - return `Tried ${requestedStrategy} first, then ${fallbackStrategy}. First attempt failed with: ${firstMessage}. Second attempt failed with: ${secondMessage}`; +function buildCreatedFeedResult(feed: FeedRecord, conversion?: FeedConversionState): CreatedFeedResult { + const resolvedConversion = conversion ?? buildPendingConversionState(); + + return { + feed, + preview: { + items: [], + isLoading: true, + }, + readinessPhase: resolvedConversion.readinessPhase, + previewStatus: resolvedConversion.previewStatus, + warnings: resolvedConversion.warnings, + retry: resolvedConversion.retry, + }; } -function buildConversionError(message: string, metadata: Partial): ConversionError { - return Object.assign(new Error(message), metadata); +function buildPendingConversionState(): FeedConversionState { + return { + readinessPhase: 'link_created', + previewStatus: 'pending', + warnings: [], + }; } -const toErrorMessage = (error: unknown): string => { - const details = extractErrorDetails(error); - const detailsMessage = details?.message?.toLowerCase(); - if ( - detailsMessage && - (detailsMessage.includes('not valid json') || detailsMessage.includes('unexpected token')) - ) { - return 'Invalid response format from feed creation API'; - } - if (details?.message) return details.message; - if (error instanceof SyntaxError) return 'Invalid response format from feed creation API'; - if (error instanceof Error) { - const normalizedMessage = error.message.toLowerCase(); - if (normalizedMessage.includes('not valid json') || normalizedMessage.includes('unexpected token')) { - return 'Invalid response format from feed creation API'; - } +function shouldLoadPreviewItems(previewStatus: FeedPreviewStatus): boolean { + return previewStatus === 'ready' || previewStatus === 'degraded'; +} - return error.message; - } - if (typeof error === 'string' && error.trim()) return error; - return 'An unexpected error occurred'; -}; +function mergePreviewResult( + conversion: FeedConversionState, + previewResult: PreviewLoadResult +): FeedConversionState { + return { + ...conversion, + previewStatus: previewResult.previewStatus, + warnings: [...conversion.warnings, ...previewResult.warnings], + }; +} -function setPreviewResult( +function commitResult( feed: FeedRecord, - preview: CreatedFeedResult['preview'], - readinessPhase: FeedReadinessPhase, - retry: CreatedFeedResult['retry'], + conversion: FeedConversionState, + previewItems: FeedPreviewItem[], + isLoading: boolean, requestId: number, setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, - requestIdReference: { current: number } + requestIdReference: { current: number }, + resultProgressReference: { current: 'none' | 'initial' | 'status' | 'preview' } ) { + let nextResult: CreatedFeedResult | undefined; + + resultProgressReference.current = previewItems.length > 0 ? 'preview' : 'status'; setState((previous) => { - if ( - requestIdReference.current !== requestId || - !previous.result || - previous.result.feed.feed_token !== feed.feed_token - ) { + if (requestIdReference.current !== requestId) { return previous; } + nextResult = { + feed, + preview: { + items: previewItems, + isLoading, + }, + readinessPhase: conversion.readinessPhase, + previewStatus: conversion.previewStatus, + warnings: conversion.warnings, + retry: conversion.retry, + }; + return { ...previous, - result: { - feed, - preview, - readinessPhase, - retry, - }, + isConverting: false, + result: nextResult, }; }); -} -function markConversionStarted( - setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void -) { - setState((previous) => ({ ...previous, isConverting: true, error: undefined })); + if (nextResult) saveFeedResultSnapshot(nextResult); } function publishCreatedFeed( - feed: FeedRecord, - retry: CreatedFeedResult['retry'], + result: CreatedFeedResult, requestId: number, setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, - requestIdReference: { current: number } -): CreatedFeedResult { - const result: CreatedFeedResult = { - feed, - preview: buildLoadingPreviewState(), - readinessPhase: 'link_created', - retry, - }; + requestIdReference: { current: number }, + resultProgressReference: { current: 'none' | 'initial' | 'status' | 'preview' } +) { + resultProgressReference.current = 'initial'; + setState((previous) => { + if (requestIdReference.current !== requestId) return previous; + + if (resultProgressReference.current !== 'initial') { + return { + ...previous, + isConverting: false, + error: undefined, + }; + } - setState((previous) => ({ ...previous, isConverting: false, result, error: undefined })); - void hydratePreview(feed, requestId, retry, setState, requestIdReference); - return result; + saveFeedResultSnapshot(result); + return { + ...previous, + isConverting: false, + error: undefined, + result, + }; + }); } function failConversion( setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, - message: string, - metadata: Partial -): never { + error: FeedCreationError +) { setState((previous) => ({ ...previous, isConverting: false, - error: message, - result: undefined, + error, })); +} - throw buildConversionError(message, metadata); +function normalizeFeedCreationError(error: unknown): FeedCreationError { + if (isFeedCreationError(error)) return error; + + if (error instanceof Error) { + return buildStructuredError( + 'network', + 'NETWORK_ERROR', + true, + 'retry', + 'primary', + error.message || 'Unable to reach the server.' + ); + } + + return buildStructuredError( + 'server', + 'UNKNOWN_ERROR', + true, + 'retry', + 'primary', + 'Unable to complete feed creation.' + ); } -const extractErrorDetails = ( - error: unknown -): { message?: string; code?: string; status?: number } | undefined => { - if (!error || typeof error !== 'object') return undefined; +function normalizeFeedCreationErrorFromResponse( + status: number, + errorPayload: unknown, + payload?: RawApiResponse +): FeedCreationError { + const envelope = resolveErrorEnvelope(errorPayload, payload); + + const kind = normalizeErrorKind(envelope?.kind, status); + const retryable = normalizeBoolean(envelope?.retryable, defaultRetryableFromStatus(status, kind)); + const nextAction = normalizeNextAction(envelope?.next_action, kind, retryable); + const retryAction = normalizeRetryAction(envelope?.retry_action, nextAction, retryable); + const code = normalizeString(envelope?.code) || fallbackErrorCode(status, kind); + const message = normalizeString(envelope?.message) || fallbackErrorMessage(status, kind, nextAction); + const nextStrategy = normalizeString(envelope?.next_strategy); + + return buildStructuredError(kind, code, retryable, nextAction, retryAction, message, status, nextStrategy); +} - const candidate = error as { - error?: { message?: unknown; code?: unknown; status?: unknown }; - message?: unknown; - code?: unknown; - status?: unknown; +function progressFromResult(result: CreatedFeedResult): ResultProgressState { + if (result.preview.items.length > 0) return 'preview'; + if (result.previewStatus === 'pending') return 'initial'; + return 'status'; +} + +function resolveErrorEnvelope(errorPayload: unknown, payload?: RawApiResponse): RawErrorEnvelope | undefined { + if (isErrorEnvelope(errorPayload)) return errorPayload; + if (isErrorEnvelope(payload?.error)) return payload.error; + if (isErrorEnvelope(payload)) return payload; + return undefined; +} + +function buildStructuredError( + kind: FeedCreationError['kind'], + code: string, + retryable: boolean, + nextAction: FeedNextAction, + retryAction: FeedRetryAction, + message: string, + status?: number, + nextStrategy?: string +): FeedCreationError { + return { + kind, + code, + retryable, + nextAction, + retryAction, + ...(nextStrategy ? { nextStrategy } : {}), + message, + ...(typeof status === 'number' ? { status } : {}), }; +} - const message = normalizeString(candidate.error?.message ?? candidate.message); - const code = normalizeString(candidate.error?.code ?? candidate.code); - const status = normalizeStatus(candidate.error?.status ?? candidate.status); - return { message, code, status }; -}; +function buildLocalError( + message: string, + kind: FeedCreationError['kind'], + nextAction: FeedNextAction +): FeedCreationError { + const retryable = nextAction === 'retry'; + return buildStructuredError( + kind, + localErrorCode(kind, nextAction), + retryable, + nextAction, + retryable ? 'primary' : 'none', + message + ); +} -function isTransientReadinessStatus(status: number): boolean { - return status === 408 || status === 425 || status === 429 || status >= 500; +function buildFallbackWarning(error: unknown): FeedStatusWarning { + if (isFeedCreationError(error)) { + return { + code: error.code, + message: error.message, + retryable: error.retryable, + nextAction: error.nextAction, + }; + } + + return { + code: 'STATUS_CHECK_FAILED', + message: PREVIEW_UNAVAILABLE_MESSAGE, + retryable: true, + nextAction: 'retry', + }; } -async function wait(durationMs: number): Promise { - await new Promise((resolve) => globalThis.setTimeout(resolve, durationMs)); +function buildPreviewWarning( + code: string, + message: string, + retryable: boolean, + nextAction: FeedNextAction +): FeedStatusWarning { + return { code, message, retryable, nextAction }; } -function retryableForFallback(error: unknown): boolean { - const details = extractErrorDetails(error); - const errorCode = details?.code?.toUpperCase(); - const status = details?.status; - if (errorCode && NON_RETRYABLE_ERROR_CODES.has(errorCode)) return false; - if (status && status < 500) return false; +function normalizeFeedRecord(raw?: RawFeedRecord): FeedRecord | undefined { + if (!raw) return undefined; + + const feedToken = normalizeString(raw.feed_token); + const publicUrl = normalizeString(raw.public_url); + const jsonPublicUrl = normalizeString(raw.json_public_url); + const url = normalizeString(raw.url); - const message = (details?.message ?? toErrorMessage(error)).toLowerCase(); - if (!details?.code && (message.includes('unauthorized') || message.includes('forbidden'))) return false; - if (!details?.code && message.includes('bad request')) return false; - if (message.includes('access token') || message.includes('authentication')) return false; - if (message.includes('unsupported strategy')) return false; - if (message.includes('invalid response format')) return false; - if (message.includes('not valid json') || message.includes('unexpected token')) return false; - if (message === 'network error') return false; - if (error instanceof SyntaxError) return false; + if (!feedToken || !publicUrl || !jsonPublicUrl || !url) return undefined; - if (status && status >= 500) return true; - if (message.includes('failed to fetch http')) return true; - return message.includes('internal server error') || message.includes('upstream timeout'); + return { + id: normalizeString(raw.id) || feedToken, + name: normalizeString(raw.name) || url, + url, + feed_token: feedToken, + public_url: publicUrl, + json_public_url: jsonPublicUrl, + created_at: normalizeString(raw.created_at) || new Date().toISOString(), + updated_at: normalizeString(raw.updated_at) || new Date().toISOString(), + }; } -function normalizeString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value : undefined; +function normalizeConversionState(raw?: RawFeedConversionState): FeedConversionState { + const readinessPhase = normalizeReadinessPhase(raw?.readiness_phase); + const previewStatus = normalizePreviewStatus(raw?.preview_status, readinessPhase); + const warnings = normalizeWarnings(raw?.warnings); + const retry = normalizeRetryState(raw?.retry); + + return { + readinessPhase, + previewStatus, + warnings, + ...(retry ? { retry } : {}), + }; +} + +function normalizeReadinessPhase(value: unknown): FeedReadinessPhase { + if ( + value === 'feed_ready' || + value === 'feed_not_ready_yet' || + value === 'preview_unavailable' || + value === 'link_created' + ) { + return value; + } + + return 'link_created'; } -function normalizeStatus(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +function normalizePreviewStatus(value: unknown, readinessPhase: FeedReadinessPhase): FeedPreviewStatus { + if (value === 'pending' || value === 'ready' || value === 'degraded' || value === 'unavailable') { + return value; + } + + if (readinessPhase === 'feed_ready') return 'ready'; + if (readinessPhase === 'preview_unavailable') return 'unavailable'; + return 'pending'; } -function normalizePreviewText(value?: string): string | undefined { - if (!value) return undefined; +function normalizeWarnings(rawWarnings: unknown): FeedStatusWarning[] { + if (!Array.isArray(rawWarnings)) return []; + + return rawWarnings + .map((warning) => normalizeWarning(warning)) + .filter((warning): warning is FeedStatusWarning => warning !== undefined); +} + +function normalizeWarning(rawWarning: unknown): FeedStatusWarning | undefined { + if (!rawWarning || typeof rawWarning !== 'object') return undefined; + + const candidate = rawWarning as { + code?: unknown; + message?: unknown; + retryable?: unknown; + next_action?: unknown; + }; - const normalized = decodeHtmlEntities(value) - .replaceAll(/<[^>]*>/g, ' ') - .replaceAll(/\s+/g, ' ') - .replaceAll(/\s+([!,.:;?])/g, '$1') - .replace(/^\d+\.\s+/, '') - .replace(/\s+\([^)]*\)\s*$/, '') - .trim(); + const code = normalizeString(candidate.code); + const message = normalizeString(candidate.message); + if (!code || !message) return undefined; - return normalized || undefined; + const retryable = normalizeBoolean(candidate.retryable, false); + const nextAction = normalizeWarningNextAction(candidate.next_action, retryable); + + return { + code, + message, + retryable, + nextAction, + }; } -function normalizePreviewItem(item: JsonFeedItem): FeedPreviewItem | undefined { - const excerptSource = item.content_text || item.content_html; - const title = normalizePreviewText(item.title) || normalizePreviewText(excerptSource) || 'Untitled item'; - const excerpt = normalizePreviewExcerpt(excerptSource, title); +function normalizeRetryState(rawRetry: unknown): FeedRetryState | undefined { + if (!rawRetry || typeof rawRetry !== 'object') return undefined; + const candidate = rawRetry as { automatic?: unknown }; return { - title, - excerpt, - publishedLabel: formatPublishedDate(item.date_published), - url: normalizePreviewUrl(item.url || item.external_url), + automatic: normalizeBoolean(candidate.automatic, false), }; } -function normalizePreviewExcerpt(value: string | undefined, title: string): string { - const excerpt = normalizePreviewText(value); - if (!excerpt || excerpt === title) return ''; - return truncateText(excerpt, 220); +function normalizeNextAction( + value: unknown, + kind: FeedCreationError['kind'], + retryable: boolean +): FeedNextAction { + if ( + value === 'enter_token' || + value === 'correct_input' || + value === 'retry' || + value === 'wait' || + value === 'none' + ) { + return value; + } + + if (kind === 'auth') return 'enter_token'; + if (kind === 'input') return 'correct_input'; + if (retryable) return 'retry'; + return 'none'; } -function normalizePreviewUrl(value?: string): string | undefined { - if (!value) return undefined; - if (!/^https?:\/\//i.test(value)) return undefined; - return value; +function normalizeWarningNextAction(value: unknown, retryable: boolean): FeedNextAction { + if (value === 'retry' || value === 'wait' || value === 'none') { + return value; + } + + return retryable ? 'retry' : 'wait'; } -function formatPublishedDate(value?: string): string { - if (!value) return ''; +function normalizeRetryAction( + value: unknown, + nextAction: FeedNextAction, + retryable: boolean +): FeedRetryAction { + if (value === 'alternate' || value === 'primary' || value === 'none') { + return value; + } + + if (!retryable || nextAction !== 'retry') return 'none'; + return 'primary'; +} - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return ''; +function normalizeErrorKind(value: unknown, status: number): FeedCreationError['kind'] { + if (value === 'auth' || value === 'input' || value === 'network' || value === 'server') return value; - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(parsed); + if (status === 401 || status === 403) return 'auth'; + if (status === 400 || status === 404 || status === 422) return 'input'; + if (isTransientHttpStatus(status)) return 'network'; + return 'server'; } -function truncateText(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; +function defaultRetryableFromStatus(status: number, kind: FeedCreationError['kind']): boolean { + if (kind === 'auth' || kind === 'input') return false; + if (kind === 'network') return true; + return isTransientHttpStatus(status) || status >= 500; +} - const clipped = value.slice(0, maxLength).trimEnd(); - const safeBoundary = clipped.lastIndexOf(' '); +function fallbackErrorCode(status: number, kind: FeedCreationError['kind']): string { + if (status === 401) return 'AUTH_REQUIRED'; + if (status === 403) return 'AUTH_FORBIDDEN'; + if (status === 400) return 'INVALID_INPUT'; + if (status === 404) return 'NOT_FOUND'; + if (status === 422) return 'UNPROCESSABLE_INPUT'; + if (isTransientHttpStatus(status)) return 'TRANSIENT_ERROR'; + if (status >= 500) return 'SERVER_ERROR'; + return `${kind.toUpperCase()}_ERROR`; +} - return `${(safeBoundary > maxLength * 0.6 ? clipped.slice(0, safeBoundary) : clipped).trimEnd()}...`; +function fallbackErrorMessage( + status: number, + kind: FeedCreationError['kind'], + nextAction: FeedNextAction +): string { + if (kind === 'auth') return 'Access token is required.'; + if (kind === 'input') return 'Check the URL and try again.'; + if (nextAction === 'wait') return 'The server is still processing the request.'; + if (isTransientHttpStatus(status) || kind === 'network') return 'Unable to reach the server. Try again.'; + return 'Unable to complete feed creation.'; } -function decodeHtmlEntities(value: string): string { - if (typeof document === 'undefined') return value; +function localErrorCode(kind: FeedCreationError['kind'], nextAction: FeedNextAction): string { + if (kind === 'auth') return 'AUTH_REQUIRED'; + if (kind === 'input' && nextAction === 'correct_input') return 'INVALID_INPUT'; + return 'LOCAL_VALIDATION_ERROR'; +} + +function isFeedCreationError(value: unknown): value is FeedCreationError { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + (candidate.kind === 'auth' || + candidate.kind === 'input' || + candidate.kind === 'network' || + candidate.kind === 'server') && + typeof candidate.code === 'string' && + typeof candidate.retryable === 'boolean' && + typeof candidate.nextAction === 'string' && + typeof candidate.retryAction === 'string' && + typeof candidate.message === 'string' + ); +} - const textarea = document.createElement('textarea'); - textarea.innerHTML = value; - return textarea.value; +function isErrorEnvelope(value: unknown): value is RawErrorEnvelope { + if (!value || typeof value !== 'object') return false; + + const candidate = value as RawErrorEnvelope; + return ( + candidate.kind !== undefined || + candidate.code !== undefined || + candidate.retryable !== undefined || + candidate.next_action !== undefined || + candidate.retry_action !== undefined || + candidate.next_strategy !== undefined || + candidate.message !== undefined + ); +} + +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +async function readJsonResponse(response: Response): Promise { + const bodyText = await response.text(); + if (!bodyText.trim()) return undefined; + + try { + return JSON.parse(bodyText) as T; + } catch { + return undefined; + } +} + +function resolveApiUrl(path: string): string { + return `/api/v1/${path.replace(/^\/+/, '')}`; +} + +function buildCreateHeaders(token: string): HeadersInit { + const normalizedToken = token.trim(); + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + + if (normalizedToken) { + headers.Authorization = `Bearer ${normalizedToken}`; + } + + return headers; +} + +function isTransientHttpStatus(status: number): boolean { + return ( + status === 408 || + status === 409 || + status === 425 || + status === 429 || + status === 500 || + status === 502 || + status === 503 || + status === 504 + ); +} + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +function isValidHttpUrl(value: string): boolean { + try { + const parsedUrl = new URL(value); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch { + return false; + } +} + +async function wait(delayMs: number, signal?: AbortSignal): Promise { + if (delayMs <= 0) return; + + await new Promise((resolve, reject) => { + const timeoutHandle = globalThis.setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + + const onAbort = () => { + globalThis.clearTimeout(timeoutHandle); + reject(new DOMException('Aborted', 'AbortError')); + }; + + if (signal) { + if (signal.aborted) { + globalThis.clearTimeout(timeoutHandle); + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +function normalizePreviewItems(items: unknown[] | undefined): FeedPreviewItem[] { + if (!Array.isArray(items)) return []; + + return items + .map((item) => normalizePreviewItem(item)) + .filter((item): item is FeedPreviewItem => item !== undefined) + .slice(0, 5); +} + +function normalizePreviewItem(value: unknown): FeedPreviewItem | undefined { + if (!value || typeof value !== 'object') return undefined; + + const candidate = value as { + title?: unknown; + excerpt?: unknown; + description?: unknown; + content_text?: unknown; + contentText?: unknown; + published_label?: unknown; + publishedLabel?: unknown; + date_published?: unknown; + datePublished?: unknown; + date_modified?: unknown; + dateModified?: unknown; + url?: unknown; + }; + + const title = normalizeString(candidate.title); + if (!title) return undefined; + + const url = normalizeString(candidate.url); + + return { + title, + excerpt: + normalizeString( + candidate.excerpt ?? candidate.description ?? candidate.content_text ?? candidate.contentText + ) || '', + publishedLabel: + normalizeString( + candidate.published_label ?? + candidate.publishedLabel ?? + candidate.date_published ?? + candidate.datePublished ?? + candidate.date_modified ?? + candidate.dateModified + ) || '', + ...(url ? { url } : {}), + }; } diff --git a/frontend/src/hooks/useStrategies.ts b/frontend/src/hooks/useStrategies.ts deleted file mode 100644 index 1054bded..00000000 --- a/frontend/src/hooks/useStrategies.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useState, useEffect } from 'preact/hooks'; -import { listStrategies } from '../api/generated'; -import { apiClient } from '../api/client'; -import type { StrategyRecord } from '../api/contracts'; - -interface StrategiesState { - strategies: StrategyRecord[]; - isLoading: boolean; - error?: string; -} - -export function useStrategies() { - const [state, setState] = useState({ - strategies: [], - isLoading: true, - }); - - const fetchStrategies = async () => { - setState((previous) => ({ ...previous, isLoading: true, error: undefined })); - - try { - const response = await listStrategies({ - client: apiClient, - }); - - if (response.error || !response.data?.success || !response.data.data?.strategies) { - throw new Error('Invalid response format from strategies API'); - } - - setState({ - strategies: response.data.data.strategies, - isLoading: false, - }); - } catch (error) { - setState({ - strategies: [], - isLoading: false, - error: error instanceof Error ? error.message : 'Failed to fetch strategies', - }); - } - }; - - useEffect(() => { - fetchStrategies(); - }, []); - - return { - strategies: state.strategies, - isLoading: state.isLoading, - error: state.error, - refetch: fetchStrategies, - }; -} diff --git a/frontend/src/routes/appRoute.ts b/frontend/src/routes/appRoute.ts new file mode 100644 index 00000000..1c43b66f --- /dev/null +++ b/frontend/src/routes/appRoute.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from 'preact/hooks'; + +export type AppRoute = + | { + kind: 'create'; + prefillUrl?: string; + } + | { + kind: 'token'; + prefillUrl?: string; + } + | { + kind: 'result'; + feedToken: string; + prefillUrl?: string; + }; + +interface RouteNavigationOptions { + replace?: boolean; +} + +interface RouteLocationLike { + pathname: string; + search: string; +} + +const ROUTE_PATHS = { + create: '/create', + token: '/token', + resultPrefix: '/result/', +} as const; + +export function readAppRoute(locationLike: RouteLocationLike = getCurrentLocation()): AppRoute { + const pathname = normalizePathname(locationLike.pathname); + const prefillUrl = new URLSearchParams(locationLike.search).get('url') ?? undefined; + + if (pathname === ROUTE_PATHS.create || pathname === '/') { + return prefillUrl ? { kind: 'create', prefillUrl } : { kind: 'create' }; + } + + if (pathname === ROUTE_PATHS.token) { + return prefillUrl ? { kind: 'token', prefillUrl } : { kind: 'token' }; + } + + if (pathname.startsWith(ROUTE_PATHS.resultPrefix)) { + const feedToken = pathname.slice(ROUTE_PATHS.resultPrefix.length); + if (feedToken) return { kind: 'result', feedToken }; + } + + return prefillUrl ? { kind: 'create', prefillUrl } : { kind: 'create' }; +} + +export function buildAppRouteHref(route: AppRoute, baseHref = getCurrentHref()): string { + const url = new URL('/', baseHref); + + if (route.kind === 'create') { + url.pathname = ROUTE_PATHS.create; + if (route.prefillUrl) url.searchParams.set('url', route.prefillUrl); + return url.toString(); + } + + if (route.kind === 'token') { + url.pathname = ROUTE_PATHS.token; + if (route.prefillUrl) url.searchParams.set('url', route.prefillUrl); + return url.toString(); + } + + url.pathname = `${ROUTE_PATHS.resultPrefix}${route.feedToken}`; + url.search = ''; + return url.toString(); +} + +export function useAppRoute() { + const [route, setRoute] = useState(() => readAppRoute()); + + useEffect(() => { + if (globalThis.window === undefined) return; + + const canonicalize = () => { + const nextRoute = readAppRoute(); + setRoute(nextRoute); + + if (globalThis.location.pathname === '/') { + replaceRoute(nextRoute); + } + }; + + canonicalize(); + globalThis.addEventListener('popstate', canonicalize); + + return () => { + globalThis.removeEventListener('popstate', canonicalize); + }; + }, []); + + const navigate = useCallback((nextRoute: AppRoute, options?: RouteNavigationOptions) => { + if (globalThis.window === undefined) return; + + const href = buildAppRouteHref(nextRoute); + if (options?.replace) { + globalThis.history.replaceState({}, '', href); + } else { + globalThis.history.pushState({}, '', href); + } + + setRoute(readAppRoute()); + }, []); + + return { + route, + navigate, + }; +} + +function normalizePathname(pathname: string): string { + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1); + } + + return pathname; +} + +function replaceRoute(route: AppRoute) { + const href = buildAppRouteHref(route); + globalThis.history.replaceState({}, '', href); +} + +function getCurrentLocation(): RouteLocationLike { + if (globalThis.window === undefined || !globalThis.location) { + return { pathname: '/', search: '' }; + } + + return { + pathname: globalThis.location.pathname, + search: globalThis.location.search, + }; +} + +function getCurrentHref(): string { + if (globalThis.window === undefined || !globalThis.location) { + return 'http://localhost/'; + } + + return globalThis.location.href; +} diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index cdfeed17..30fbce0d 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -3,16 +3,22 @@ } .page-shell { + --footer-nav-reserve: calc(5.25rem + env(safe-area-inset-bottom, 0px)); + min-height: 100vh; display: grid; grid-template-rows: minmax(0, 1fr) auto; } .page-main { + min-height: 100%; + display: flex; + flex-direction: column; width: 100%; max-width: var(--layout-page-max-width); margin: 0 auto; - padding: clamp(0.85rem, 3vh, 2rem) clamp(var(--space-3), 3vw, var(--space-4)) var(--space-5); + padding: clamp(0.85rem, 3vh, 2rem) clamp(var(--space-3), 3vw, var(--space-4)) + calc(var(--space-5) + var(--footer-nav-reserve)); } /* Layout Engine */ @@ -51,6 +57,14 @@ justify-items: center; } +.workspace-content { + width: 100%; + display: grid; + gap: var(--space-4); + align-content: center; + min-height: clamp(20rem, 52vh, 36rem); +} + .workspace-hero { gap: var(--space-1); text-align: center; @@ -180,6 +194,10 @@ color: var(--danger); } +.field-help--warning { + color: var(--text-body); +} + .input { width: 100%; min-width: 0; @@ -609,14 +627,17 @@ a:focus-visible { } .utility-strip { - gap: var(--space-2); width: 100%; - max-width: var(--layout-field-max-width); + max-width: var(--layout-page-max-width); } .utility-strip__items { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + align-items: center; + justify-content: center; gap: var(--space-2); - justify-items: center; } .utility-link, @@ -646,18 +667,42 @@ a:focus-visible { text-transform: uppercase; } +.app-footer { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 20; + display: grid; + justify-items: center; + padding: var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom, 0px)); + background: linear-gradient(180deg, transparent 0%, rgb(var(--color-rgb-black) / 68%) 56%); + backdrop-filter: blur(4px); +} + +.app-footer[data-visible="false"] { + display: none; +} + +.app-footer__inner { + width: 100%; + max-width: var(--layout-page-max-width); +} + @media (width >= 48rem) { .page-main { padding-top: clamp(1rem, 6vh, 4rem); } .utility-strip__items { - grid-auto-flow: column; gap: var(--space-4); } } @media (width < 48rem) { + .page-shell { + --footer-nav-reserve: 0px; + } + .page-main, .workspace-shell { width: 100%; @@ -669,6 +714,10 @@ a:focus-visible { gap: var(--space-4); } + .workspace-content { + min-height: clamp(16rem, 42vh, 26rem); + } + .form-shell--minimal, .result-shell { padding: var(--space-4); @@ -717,6 +766,32 @@ a:focus-visible { min-width: 4rem; padding-inline: var(--space-3); } + + .app-footer { + position: static; + inset-inline: auto; + bottom: auto; + z-index: auto; + margin-top: auto; + padding: var(--space-5) var(--space-3) calc(var(--space-3) + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--border-subtle); + background: transparent; + backdrop-filter: none; + } + + .utility-strip__items { + grid-auto-flow: row; + grid-auto-columns: 1fr; + justify-items: center; + gap: calc(var(--space-1) * 0.9); + } + + .utility-link, + .utility-button { + color: var(--text-faint); + font-size: var(--font-size-00); + opacity: 0.92; + } } @keyframes spin { diff --git a/frontend/src/utils/feedWorkflowStorage.ts b/frontend/src/utils/feedWorkflowStorage.ts new file mode 100644 index 00000000..6105e39d --- /dev/null +++ b/frontend/src/utils/feedWorkflowStorage.ts @@ -0,0 +1,215 @@ +import type { CreatedFeedResult, FeedPreviewItem, FeedStatusWarning } from '../api/contracts'; +import { getPersistentStorage } from './persistentStorage'; + +const FEED_DRAFT_KEY = 'html2rss_feed_draft_state'; +const FEED_RESULT_KEY_PREFIX = 'html2rss_feed_result_snapshot'; + +export interface FeedDraftState { + url: string; +} + +export interface FeedResultSnapshot { + savedAt: string; + result: CreatedFeedResult; +} + +export function loadFeedDraftState(): FeedDraftState | undefined { + const storedState = parseJson( + getPersistentStorage().getItem(FEED_DRAFT_KEY), + isFeedDraftState + ); + + return storedState ? normalizeFeedDraftState(storedState) : undefined; +} + +export function saveFeedDraftState(state: FeedDraftState): void { + const normalizedState = normalizeFeedDraftState(state); + if (!normalizedState) return; + + getPersistentStorage().setItem(FEED_DRAFT_KEY, JSON.stringify(normalizedState)); +} + +export function clearFeedDraftState(): void { + getPersistentStorage().removeItem(FEED_DRAFT_KEY); +} + +export function loadFeedResultSnapshot(feedToken: string): FeedResultSnapshot | undefined { + const storageKey = buildFeedResultKey(feedToken); + if (!storageKey) return undefined; + + const snapshot = parseJson( + getPersistentStorage().getItem(storageKey), + isFeedResultSnapshot + ); + + return snapshot ? normalizeFeedResultSnapshot(snapshot) : undefined; +} + +export function loadFeedResultState(feedToken: string): CreatedFeedResult | undefined { + return loadFeedResultSnapshot(feedToken)?.result; +} + +export function saveFeedResultSnapshot(result: CreatedFeedResult): void { + const storageKey = buildFeedResultKey(result.feed.feed_token); + if (!storageKey) return; + + const snapshot: FeedResultSnapshot = { + savedAt: new Date().toISOString(), + result, + }; + + getPersistentStorage().setItem(storageKey, JSON.stringify(snapshot)); +} + +export function clearFeedResultSnapshot(feedToken: string): void { + const storageKey = buildFeedResultKey(feedToken); + if (!storageKey) return; + + getPersistentStorage().removeItem(storageKey); +} + +function buildFeedResultKey(feedToken: string): string | undefined { + const normalizedFeedToken = feedToken.trim(); + if (!normalizedFeedToken) return undefined; + + return `${FEED_RESULT_KEY_PREFIX}:${normalizedFeedToken}`; +} + +function normalizeFeedDraftState(state: FeedDraftState): FeedDraftState | undefined { + const url = state.url.trim(); + if (!url) return undefined; + + return { url }; +} + +function isFeedDraftState(value: unknown): value is FeedDraftState { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return typeof candidate.url === 'string'; +} + +function isFeedResultSnapshot(value: unknown): value is FeedResultSnapshot { + if (!isRecord(value)) return false; + + const candidate = value as Partial; + if (typeof candidate.savedAt !== 'string' || !isRecord(candidate.result)) return false; + + const result = candidate.result as Partial; + return ( + typeof result.readinessPhase === 'string' && + typeof result.previewStatus === 'string' && + isFeedRecord(result.feed) && + isFeedPreviewState(result.preview) && + (result.warnings === undefined || isFeedStatusWarnings(result.warnings)) && + (result.retry === undefined || isFeedRetryState(result.retry)) + ); +} + +function parseJson( + value: string | null | undefined, + guard: (candidate: unknown) => candidate is T +): T | undefined { + if (!value) return undefined; + + try { + const parsed = JSON.parse(value) as unknown; + return guard(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function normalizeFeedResultSnapshot(snapshot: FeedResultSnapshot): FeedResultSnapshot | undefined { + const preview = normalizePreviewState(snapshot.result.preview); + if (!preview) return undefined; + + return { + savedAt: snapshot.savedAt, + result: { + ...snapshot.result, + preview, + readinessPhase: snapshot.result.readinessPhase, + previewStatus: snapshot.result.previewStatus, + warnings: normalizeWarnings(snapshot.result.warnings), + retry: snapshot.result.retry, + }, + }; +} + +function normalizePreviewState(preview: unknown): CreatedFeedResult['preview'] | undefined { + if (!isFeedPreviewState(preview)) return undefined; + + return { + items: normalizePreviewItems(preview.items), + isLoading: preview.isLoading, + }; +} + +function normalizePreviewItems(items: unknown[]): FeedPreviewItem[] { + return items.filter((item) => isFeedPreviewItem(item)); +} + +function normalizeWarnings(warnings: FeedStatusWarning[] | undefined): FeedStatusWarning[] { + return Array.isArray(warnings) ? warnings.filter((warning) => isFeedStatusWarning(warning)) : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +function isFeedRecord(value: unknown): value is CreatedFeedResult['feed'] { + if (!isRecord(value)) return false; + + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.url === 'string' && + typeof value.feed_token === 'string' && + typeof value.public_url === 'string' && + typeof value.json_public_url === 'string' && + typeof value.created_at === 'string' && + typeof value.updated_at === 'string' + ); +} + +function isFeedPreviewState(value: unknown): value is CreatedFeedResult['preview'] { + if (!isRecord(value)) return false; + if (!Array.isArray(value.items) || typeof value.isLoading !== 'boolean') return false; + + return value.items.every((item) => isFeedPreviewItem(item)); +} + +function isFeedPreviewItem(value: unknown): value is FeedPreviewItem { + if (!isRecord(value)) return false; + + return ( + typeof value.title === 'string' && + typeof value.excerpt === 'string' && + typeof value.publishedLabel === 'string' && + (value.url === undefined || typeof value.url === 'string') + ); +} + +function isFeedStatusWarnings(value: unknown): value is FeedStatusWarning[] { + return Array.isArray(value) && value.every((warning) => isFeedStatusWarning(warning)); +} + +function isFeedStatusWarning(value: unknown): value is FeedStatusWarning { + if (!isRecord(value)) return false; + + return ( + typeof value.code === 'string' && + typeof value.message === 'string' && + typeof value.retryable === 'boolean' && + (value.nextAction === 'enter_token' || + value.nextAction === 'correct_input' || + value.nextAction === 'retry' || + value.nextAction === 'wait' || + value.nextAction === 'none') + ); +} + +function isFeedRetryState(value: unknown): value is { automatic: boolean } { + return isRecord(value) && typeof value.automatic === 'boolean'; +} diff --git a/frontend/src/utils/persistentStorage.ts b/frontend/src/utils/persistentStorage.ts new file mode 100644 index 00000000..5c4400ad --- /dev/null +++ b/frontend/src/utils/persistentStorage.ts @@ -0,0 +1,36 @@ +const memoryStorage = (() => { + const store = new Map(); + + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + // Storage#getItem returns null when a key is missing. + // eslint-disable-next-line unicorn/no-null + getItem: (key: string) => store.get(key) ?? null, + // Storage#key returns null when the index is out of range. + // eslint-disable-next-line unicorn/no-null + key: (index: number) => [...store.keys()][index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + } as Storage; +})(); + +export function getPersistentStorage(): Storage { + if (globalThis.window === undefined) return memoryStorage; + + try { + return globalThis.localStorage ?? globalThis.sessionStorage ?? memoryStorage; + } catch { + try { + return globalThis.sessionStorage ?? memoryStorage; + } catch { + return memoryStorage; + } + } +} diff --git a/public/openapi.yaml b/public/openapi.yaml index ab990919..b1ff2efb 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -103,13 +103,10 @@ paths: application/json: schema: properties: - strategy: - type: string url: type: string required: - url - - strategy type: object responses: '201': @@ -119,6 +116,20 @@ paths: properties: data: properties: + conversion: + properties: + preview_status: + type: string + readiness_phase: + type: string + warnings: + items: {} + type: array + required: + - readiness_phase + - preview_status + - warnings + type: object feed: properties: created_at: @@ -133,8 +144,6 @@ paths: type: string public_url: type: string - strategy: - type: string updated_at: type: string url: @@ -143,7 +152,6 @@ paths: - id - name - url - - strategy - feed_token - public_url - json_public_url @@ -152,6 +160,7 @@ paths: type: object required: - feed + - conversion type: object meta: properties: @@ -177,11 +186,23 @@ paths: properties: code: type: string + kind: + type: string message: type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean required: - message - code + - kind + - retryable + - next_action + - retry_action type: object success: type: boolean @@ -199,11 +220,23 @@ paths: properties: code: type: string + kind: + type: string message: type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean required: - message - code + - kind + - retryable + - next_action + - retry_action type: object success: type: boolean @@ -281,6 +314,73 @@ paths: summary: Render feed by token tags: - Feeds + "/feeds/{token}/status": + get: + description: Get feed status + operationId: getFeedStatus + parameters: + - in: path + name: token + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + conversion: + properties: + preview_status: + type: string + readiness_phase: + type: string + warnings: + items: {} + type: array + required: + - readiness_phase + - preview_status + - warnings + type: object + feed: + properties: + feed_token: + type: string + json_public_url: + type: string + name: + type: string + public_url: + type: string + url: + type: string + required: + - name + - url + - feed_token + - public_url + - json_public_url + type: object + required: + - feed + - conversion + type: object + success: + type: boolean + required: + - success + - data + type: object + description: Returns readiness and degradation metadata for a generated + feed. + security: [] + summary: Get feed status + tags: + - Feeds "/health": get: description: Authenticated health check @@ -339,11 +439,23 @@ paths: properties: code: type: string + kind: + type: string message: type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean required: - message - code + - kind + - retryable + - next_action + - retry_action type: object success: type: boolean @@ -361,11 +473,23 @@ paths: properties: code: type: string + kind: + type: string message: type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean required: - message - code + - kind + - retryable + - next_action + - retry_action type: object success: type: boolean diff --git a/public/rss.xsl b/public/rss.xsl index 02d8cb25..792de60e 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -361,7 +361,7 @@
- +

diff --git a/public/shared-ui.css b/public/shared-ui.css index a17b0206..a1dcd12a 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -102,12 +102,6 @@ body { margin: 0; min-width: 20rem; color: var(--text-body); - font-family: var(--font-family-ui); - font-size: var(--font-size-0); - line-height: var(--line-height-base); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizelegibility; background: transparent; } @@ -116,7 +110,12 @@ button, input, select, textarea { - font: inherit; + font-family: var(--font-family-ui); + font-size: var(--font-size-0); + line-height: var(--line-height-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizelegibility; } a { diff --git a/spec/html2rss/web/api/v1/contract_spec.rb b/spec/html2rss/web/api/v1/contract_spec.rb new file mode 100644 index 00000000..f0e5cbd2 --- /dev/null +++ b/spec/html2rss/web/api/v1/contract_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../../app' + +RSpec.describe Html2rss::Web::Api::V1::Contract do + describe '.failure_payload' do + it 'maps auto-source-disabled to non-retryable server failure', :aggregate_failures do + payload = described_class.failure_payload(Html2rss::Web::AutoSourceDisabledError.new) + + expect(payload).to include( + code: Html2rss::Web::ForbiddenError::CODE, + kind: 'server', + retryable: false, + next_action: 'none', + retry_action: 'none' + ) + expect(payload).not_to have_key(:next_strategy) + end + + it 'uses class-based forbidden mapping even when message matches auto-source text', :aggregate_failures do + payload = described_class.failure_payload( + Html2rss::Web::ForbiddenError.new(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled]) + ) + + expect(payload).to include( + code: Html2rss::Web::ForbiddenError::CODE, + kind: 'input', + retryable: false, + next_action: 'correct_input', + retry_action: 'none' + ) + end + + it 'classifies transport failures without message matching', :aggregate_failures do + payload = described_class.failure_payload(Timeout::Error.new('upstream unavailable')) + + expect(payload).to include( + code: Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error], + kind: 'network', + retryable: true, + next_action: 'retry', + retry_action: 'primary' + ) + expect(payload).not_to have_key(:next_strategy) + end + end +end diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index a26256a1..a84eff97 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -17,7 +17,8 @@ def feed_result message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -28,7 +29,20 @@ def service_error_result message: 'Internal Server Error', ttl_seconds: 600, cache_key: 'feed_result:error', - error_message: 'upstream timeout' + error_message: 'upstream timeout', + error_kind: :network + ) + end + + def empty_result + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:empty', + error_message: nil, + error_kind: nil ) end @@ -72,6 +86,50 @@ def json_feed_response_for(token) json_feed_headers_tuple end + def stub_feed_status_openapi_example + stub_feed_status_dependencies + allow(Rack::MockRequest).to receive(:env_for).and_wrap_original do |original, uri = '', opts = {}| + env = original.call(uri, opts) + rewrite_openapi_placeholder_path!(env, uri.to_s) + end + end + + def stub_feed_status_dependencies + stub_feed_status_request + stub_feed_status_source + stub_feed_status_metadata + stub_feed_status_service + end + + def stub_feed_status_request + allow(Html2rss::Web::Feeds::Request).to receive(:call).and_return(Struct.new(:token).new('{token}')) + end + + def stub_feed_status_source + allow(Html2rss::Web::Feeds::SourceResolver).to receive(:call).and_return( + Struct.new(:generator_input).new({ channel: { url: feed_url } }) + ) + end + + def stub_feed_status_metadata + allow(Html2rss::Web::Api::V1::FeedMetadata).to receive(:site_title_for).and_return('Example Feed') + end + + def stub_feed_status_service + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + end + + def rewrite_openapi_placeholder_path!(env, request_uri) + return env unless request_uri.include?('/api/v1/feeds/%7Btoken%7D/status') + + raw_status_path = '/api/v1/feeds/{token}/status' + env['PATH_INFO'] = raw_status_path + env['REQUEST_URI'] = raw_status_path + env['ORIGINAL_FULLPATH'] = raw_status_path + env['ORIGINAL_PATH_INFO'] = raw_status_path + env + end + def stub_json_feed_success allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) @@ -201,9 +259,14 @@ def expected_featured_feeds let(:perform_request) { -> { get '/api/v1/health' } } - it_behaves_like 'api error contract', - status: 401, - code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized] + it_behaves_like 'api error contract', { + status: 401, + code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized], + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none' + } it 'returns health status when token is valid', :aggregate_failures do header 'Authorization', "Bearer #{health_token}" @@ -251,7 +314,11 @@ def expected_featured_feeds expect(last_response.status).to eq(500) json = expect_error_response(last_response, - code: Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error]) + code: Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error], + kind: 'server', + retryable: false, + next_action: 'none', + retry_action: 'none') expect(json.dig('error', 'message')).to eq(Html2rss::Web::Api::V1::Contract::MESSAGES[:health_check_failed]) end end @@ -384,6 +451,78 @@ def expected_featured_feeds ) end + describe 'GET /api/v1/feeds/:token/status', openapi: { + summary: 'Get feed status', + description: 'Returns readiness and degradation metadata for a generated feed.', + operation_id: 'getFeedStatus', + tags: ['Feeds'], + parameters: [ + { + name: :token, + in: :path, + required: true, + schema: { type: :string } + } + ], + security: [] + } do + it 'documents structured feed status for ready feeds', :aggregate_failures do + stub_feed_status_openapi_example + + get '/api/v1/feeds/%7Btoken%7D/status' + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'feed', 'feed_token')).to eq('{token}') + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [] + ) + end + end + + it 'exposes structured feed status for ready feeds', :aggregate_failures, openapi: false do + token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + + get "/api/v1/feeds/#{token}/status" + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect(json.dig('data', 'feed', 'feed_token')).to eq(token) + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [] + ) + end + + it 'exposes degraded preview warnings with explicit retry guidance', :aggregate_failures, openapi: false do # rubocop:disable RSpec/ExampleLength + token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/degraded", strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) + + get "/api/v1/feeds/#{token}/status" + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'degraded', + warnings: [ + { + 'code' => 'preview_partial', + 'message' => 'Preview content could not be fully verified.', + 'retryable' => true, + 'next_action' => 'retry' + } + ] + ) + end + it 'returns forbidden when auto source is disabled', :aggregate_failures do unique_url = "#{feed_url}/disabled" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'faraday') @@ -433,6 +572,32 @@ def expected_featured_feeds expect([status, content_type, title]).to eq([500, 'application/feed+json', 'Error']) expect(cache_control).to include('no-store') end + + it 'returns 422 for empty extraction feeds in xml representation', :aggregate_failures, openapi: false do + token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/empty-xml", strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') + + get "/api/v1/feeds/#{token}.xml" + + expect(last_response.status).to eq(422) + expect(last_response.content_type).to include('application/xml') + expect(last_response.headers['Cache-Control']).to include('max-age=600') + end + + it 'returns 422 for empty extraction feeds in json feed representation', :aggregate_failures, openapi: false do + token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/empty-json", strategy: 'faraday') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(empty_result) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","title":"Content Extraction Issue","items":[]}') + + get "/api/v1/feeds/#{token}.json" + + expect(last_response.status).to eq(422) + expect(last_response.content_type).to eq('application/feed+json') + expect(last_response.headers['Cache-Control']).to include('max-age=600') + expect(JSON.parse(last_response.body).fetch('title')).to eq('Content Extraction Issue') + end end describe 'POST /api/v1/feeds', openapi: { @@ -443,8 +608,7 @@ def expected_featured_feeds } do let(:request_params) do { - url: feed_url, - strategy: 'faraday' + url: feed_url } end @@ -459,11 +623,16 @@ def expected_featured_feeds header 'Authorization', nil end - it_behaves_like 'api error contract', - status: 401, - code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized] + it_behaves_like 'api error contract', { + status: 401, + code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized], + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none' + } - it 'creates a feed when request is valid', :aggregate_failures do + it 'creates a feed when request is valid', :aggregate_failures do # rubocop:disable RSpec/ExampleLength header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' post '/api/v1/feeds', request_params.to_json @@ -471,18 +640,24 @@ def expected_featured_feeds expect(last_response.status).to eq(201) json = expect_success_response(last_response) expect_feed_payload(json) + expect_conversion_payload( + json, + readiness_phase: 'link_created', + preview_status: 'pending', + warnings: [] + ) expect(last_response.headers['Content-Type']).to include('application/json') end it 'normalizes hostname-only input to https before feed creation', :aggregate_failures do - post_feed_request(url: 'example.com/articles', strategy: 'faraday') + post_feed_request(url: 'example.com/articles') expect(last_response.status).to eq(201) json = expect_success_response(last_response) expect(json.dig('data', 'feed', 'url')).to eq('https://example.com/articles') end - it 'returns forbidden for authenticated requests when auto source is disabled', :aggregate_failures do + it 'returns forbidden for authenticated requests when auto source is disabled', :aggregate_failures do # rubocop:disable RSpec/ExampleLength header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' @@ -491,7 +666,14 @@ def expected_featured_feeds end expect(last_response.status).to eq(403) - json = expect_error_response(last_response, code: Html2rss::Web::Api::V1::Contract::CODES[:forbidden]) + json = expect_error_response( + last_response, + code: Html2rss::Web::Api::V1::Contract::CODES[:forbidden], + kind: 'server', + retryable: false, + next_action: 'none', + retry_action: 'none' + ) expect(json.dig('error', 'message')).to eq(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled]) end end diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 86f3a6c4..9ebe167a 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -46,7 +46,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -65,6 +66,42 @@ .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') end + describe 'GET /create, /token, /result/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers + it 'returns not found for create route', :aggregate_failures do + get '/create' + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq('') + end + + it 'returns not found for token route', :aggregate_failures do + get '/token' + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq('') + end + + it 'returns not found for result route', :aggregate_failures do + get '/result/generated-token' + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq('') + end + + it 'returns not found for SPA app routes in development mode', :aggregate_failures do + ClimateControl.modify('RACK_ENV' => 'development') do + get '/create' + expect(last_response.status).to eq(404) + + get '/token' + expect(last_response.status).to eq(404) + + get '/result/generated-token' + expect(last_response.status).to eq(404) + end + end + end + describe 'GET /api/v1/feeds/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers it 'returns unauthorized for invalid tokens' do get '/api/v1/feeds/invalid-token', {}, { 'HTTP_ACCEPT' => 'application/xml' } @@ -182,6 +219,31 @@ ) end + it 'returns structured feed status metadata for ready feeds', :aggregate_failures do + get "/api/v1/feeds/#{feed_token}/status" + + expect(last_response.status).to eq(200) + json = expect_success_response(last_response) + expect_conversion_payload( + json, + readiness_phase: 'feed_ready', + preview_status: 'ready', + warnings: [] + ) + end + + it 'returns 422 when extraction yields an empty feed warning', :aggregate_failures do + unique_empty_url = "#{feed_url}/empty-warning" + empty_token = Html2rss::Web::Auth.generate_feed_token(account[:username], unique_empty_url, strategy: 'faraday') + stub_empty_feed_warning_result + + get "/api/v1/feeds/#{empty_token}.json" + + expect(last_response.status).to eq(422) + expect(last_response.headers['Content-Type']).to eq('application/feed+json') + expect(JSON.parse(last_response.body).fetch('title')).to eq('Content Extraction Issue') + end + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def stub_escaped_feed_token(raw_token:, encoded_token:) escaped_token_payload = instance_double( @@ -197,14 +259,31 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) .to receive(:validate_and_decode).with(raw_token, feed_url, anything) .and_return(escaped_token_payload) end + + # @return [void] + def stub_empty_feed_warning_result + Html2rss::Web::Feeds::Cache.clear!(reason: 'spec') + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return( + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:empty', + error_message: nil, + error_kind: nil + ) + ) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","title":"Content Extraction Issue","items":[]}') + end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end describe 'POST /api/v1/feeds' do # rubocop:disable RSpec/MultipleMemoizedHelpers let(:request_payload) do { - url: feed_url, - strategy: 'faraday' + url: feed_url } end @@ -231,7 +310,15 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) expect(last_response.status).to eq(401) expect(last_response.content_type).to include('application/json') - expect(json_body).to include('error' => include('code' => 'UNAUTHORIZED')) + expect(json_body).to include( + 'error' => include( + 'code' => 'UNAUTHORIZED', + 'kind' => 'auth', + 'retryable' => false, + 'next_action' => 'enter_token', + 'retry_action' => 'none' + ) + ) end end @@ -245,7 +332,15 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) expect(last_response.status).to eq(400) expect(last_response.content_type).to include('application/json') - expect(json_body).to include('error' => include('message' => 'Invalid JSON payload')) + expect(json_body).to include( + 'error' => include( + 'message' => 'Invalid JSON payload', + 'kind' => 'input', + 'retryable' => false, + 'next_action' => 'correct_input', + 'retry_action' => 'none' + ) + ) end it 'returns bad request when URL is missing' do @@ -253,7 +348,13 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) expect(last_response.status).to eq(400) expect(json_body).to include( - 'error' => include('message' => 'URL parameter is required') + 'error' => include( + 'message' => 'URL parameter is required', + 'kind' => 'input', + 'retryable' => false, + 'next_action' => 'correct_input', + 'retry_action' => 'none' + ) ) end @@ -264,16 +365,13 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) expect(last_response.status).to eq(403) expect(json_body).to include( - 'error' => include('message' => 'URL not allowed for this account') - ) - end - - it 'returns bad request for unsupported strategy' do - post '/api/v1/feeds', request_payload.merge(strategy: 'unsupported').to_json, auth_headers - - expect(last_response.status).to eq(400) - expect(json_body).to include( - 'error' => include('message' => 'Unsupported strategy') + 'error' => include( + 'message' => 'URL not allowed for this account', + 'kind' => 'input', + 'retryable' => false, + 'next_action' => 'correct_input', + 'retry_action' => 'none' + ) ) end @@ -284,11 +382,17 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) expect(last_response.status).to eq(500) expect(json_body).to include( - 'error' => include('message' => 'Failed to create feed') + 'error' => include( + 'message' => 'Failed to create feed', + 'kind' => 'server', + 'retryable' => true, + 'next_action' => 'retry', + 'retry_action' => 'primary' + ) ) end - it 'returns created feed metadata' do + it 'returns created feed metadata' do # rubocop:disable RSpec/ExampleLength post '/api/v1/feeds', request_payload.to_json, auth_headers expect(last_response.status).to eq(201) @@ -300,6 +404,12 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) 'public_url' => "/api/v1/feeds/#{feed_token}", 'json_public_url' => "/api/v1/feeds/#{feed_token}.json" ) + expect(json_body.dig('data', 'feed')).not_to have_key('strategy') + expect(json_body.dig('data', 'conversion')).to include( + 'readiness_phase' => 'link_created', + 'preview_status' => 'pending', + 'warnings' => [] + ) end end end diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index f34ae32e..aff0d999 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -36,7 +36,8 @@ def static_feed_result(ttl:) message: nil, ttl_seconds: Html2rss::Web::CacheTtl.seconds_from_minutes(ttl), cache_key: 'feed_result:spec', - error_message: nil + error_message: nil, + error_kind: nil ) end @@ -53,7 +54,8 @@ def static_service_error_result message: 'Internal Server Error', ttl_seconds: 600, cache_key: 'feed_result:error', - error_message: 'upstream timeout' + error_message: 'upstream timeout', + error_kind: :network ) end @@ -87,10 +89,34 @@ def app = described_class get '/' expect(last_response).to be_ok + expect(last_response.body).not_to include('html2rss-web API (development)') expect(last_response.headers['Content-Security-Policy']).to include("default-src 'none'") + expect(last_response.headers['Content-Security-Policy']).to include("script-src 'self'") + expect(last_response.headers['Content-Security-Policy']).to include("style-src 'self'") + expect(last_response.headers['Content-Security-Policy']).not_to include("'unsafe-inline'") expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000') end + it 'serves an API-only landing page on root in development', :aggregate_failures do + ClimateControl.modify('RACK_ENV' => 'development') do + get '/' + end + + expect(last_response).to be_ok + expect(last_response.body).to include('html2rss-web API (development)') + expect(last_response.body).to include('/api/v1') + expect(last_response.body).to include('http://127.0.0.1:4001/') + end + + it 'does not render SPA app routes in development' do + ClimateControl.modify('RACK_ENV' => 'development') do + get '/create' + end + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq('') + end + it 'does not serve the removed legacy frontend entrypoint' do get '/frontend/index.html' @@ -142,8 +168,7 @@ def app = described_class it 'returns method not allowed for unsupported verbs on token feed routes' do post '/api/v1/feeds/test-token' - expect(last_response.status).to eq(405) - expect(last_response.headers['Allow']).to eq('GET') + expect(last_response.status).to eq(404) end it 'coerces string ttl values before cache expiry math' do diff --git a/spec/html2rss/web/boot/sentry_spec.rb b/spec/html2rss/web/boot/sentry_spec.rb new file mode 100644 index 00000000..4db6b041 --- /dev/null +++ b/spec/html2rss/web/boot/sentry_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Boot::Sentry do + let(:sentry_dsn) { 'https://example@sentry.invalid/1' } + let(:captured_config) { Struct.new(:dsn, :environment, :enable_logs, :send_default_pii, :release).new } + let(:captured_scope) do + Class.new do + attr_reader :tags + + def initialize + @tags = {} + end + + def set_tags(**tags) + @tags = tags + end + end.new + end + let(:fake_sentry) do + config = captured_config + scope = captured_scope + + Module.new.tap do |mod| + mod.define_singleton_method(:initialized?) { false } + mod.define_singleton_method(:init) do |&block| + block.call(config) + end + mod.define_singleton_method(:configure_scope) do |&block| + block.call(scope) + end + end + end + + before do + stub_const('Sentry', fake_sentry) + end + + it 'configures release, environment, and scope tags when a dsn is present', :aggregate_failures do + stub_runtime_env_for_sentry('production') + described_class.send(:initialize_sentry!) + + expect_sentry_configuration + end + + it 'does nothing when a dsn is not present' do + allow(Html2rss::Web::RuntimeEnv).to receive(:sentry_enabled?).and_return(false) + + expect(described_class.send(:configure?)).to be(false) + end + + def stub_runtime_env_for_sentry(rack_env) + allow(Html2rss::Web::RuntimeEnv).to receive_messages( + sentry_enabled?: true, + sentry_dsn: sentry_dsn, + rack_env: rack_env, + build_tag: '2026-03-27', + git_sha: 'abc1234', + sentry_logs_enabled?: false + ) + end + + def expect_sentry_configuration + expect(captured_config).to have_attributes( + dsn: sentry_dsn, + environment: 'production', + enable_logs: false, + send_default_pii: false, + release: '2026-03-27+abc1234' + ) + expect_sentry_scope_tags + end + + def expect_sentry_scope_tags + expect(captured_scope.tags).to eq( + release: '2026-03-27+abc1234', + environment: 'production' + ) + end +end diff --git a/spec/html2rss/web/error_responder_spec.rb b/spec/html2rss/web/error_responder_spec.rb index fe5d7e0f..14954ba5 100644 --- a/spec/html2rss/web/error_responder_spec.rb +++ b/spec/html2rss/web/error_responder_spec.rb @@ -67,13 +67,17 @@ def xml_preferred_feed_error_response [response['Content-Type'], body.include?('Invalid token')] end - def expected_api_error_response + def expected_api_error_response # rubocop:disable Metrics/MethodLength [500, 'application/json', { 'success' => false, 'error' => { 'code' => Html2rss::Web::Api::V1::Contract::CODES[:internal_server_error], - 'message' => 'Internal Server Error' + 'message' => 'Internal Server Error', + 'kind' => 'server', + 'retryable' => true, + 'next_action' => 'retry', + 'retry_action' => 'primary' } }] end diff --git a/spec/html2rss/web/feed_notice_text_spec.rb b/spec/html2rss/web/feed_notice_text_spec.rb new file mode 100644 index 00000000..37649748 --- /dev/null +++ b/spec/html2rss/web/feed_notice_text_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::FeedNoticeText do + describe '.empty_feed_item' do + subject(:message) { described_class.empty_feed_item(url: 'https://example.com/articles') } + + it 'includes actionable product guidance' do + expect(message).to include('What you can do:') + expect(message).to include('Try again in a few moments') + end + + it 'does not mention hidden strategy controls' do + expect(message).not_to include('browserless strategy') + expect(message).not_to include('Try another strategy') + end + end +end diff --git a/spec/html2rss/web/feeds/cache_spec.rb b/spec/html2rss/web/feeds/cache_spec.rb index 0bcb2b36..cc9c0224 100644 --- a/spec/html2rss/web/feeds/cache_spec.rb +++ b/spec/html2rss/web/feeds/cache_spec.rb @@ -18,7 +18,8 @@ message: nil, ttl_seconds: 60, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/html2rss/web/feeds/json_renderer_spec.rb b/spec/html2rss/web/feeds/json_renderer_spec.rb index d14fef9c..15aa5b32 100644 --- a/spec/html2rss/web/feeds/json_renderer_spec.rb +++ b/spec/html2rss/web/feeds/json_renderer_spec.rb @@ -21,7 +21,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index d1898199..8b239865 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -13,7 +13,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end let(:static_config) do @@ -81,7 +82,8 @@ message: 'Internal Server Error', ttl_seconds: 600, cache_key: 'feed_result:error', - error_message: 'timeout' + error_message: 'timeout', + error_kind: :network ) end @@ -102,6 +104,53 @@ end end + context 'with an empty extraction result' do + subject(:write_response) do + described_class.call( + request: request_for(path: '/example.json', accept: 'application/feed+json'), + target_kind: :static, + identifier: 'example.json' + ) + end + + let(:result) do + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:empty', + error_message: nil, + error_kind: nil + ) + end + + before do + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) + allow(Html2rss::Web::Feeds::JsonRenderer) + .to receive(:call) + .with(result) + .and_return('{"title":"Content Extraction Issue"}') + end + + it 'returns 422 while preserving warning feed payload' do + expect(response_tuple(write_response)).to eq( + [422, 'application/feed+json', '{"title":"Content Extraction Issue"}'] + ) + end + + it 'emits empty extraction as a failure outcome' do + write_response + + expect(Html2rss::Web::Observability).to have_received(:emit).with( + event_name: 'feed.render', + outcome: 'failure', + details: include(strategy: :faraday, url: 'https://example.com', reason: 'content_extraction_empty'), + level: :warn + ) + end + end + context 'when response rendering fails after feed generation succeeds' do subject(:write_response) do described_class.call( diff --git a/spec/html2rss/web/feeds/rss_renderer_spec.rb b/spec/html2rss/web/feeds/rss_renderer_spec.rb index ca07c26a..108b7d40 100644 --- a/spec/html2rss/web/feeds/rss_renderer_spec.rb +++ b/spec/html2rss/web/feeds/rss_renderer_spec.rb @@ -21,7 +21,8 @@ message: nil, ttl_seconds: 600, cache_key: 'feed_result:test', - error_message: nil + error_message: nil, + error_kind: nil ) end diff --git a/spec/html2rss/web/json_feed_builder_spec.rb b/spec/html2rss/web/json_feed_builder_spec.rb new file mode 100644 index 00000000..5aede1fe --- /dev/null +++ b/spec/html2rss/web/json_feed_builder_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::JsonFeedBuilder do + describe '.build_empty_feed_warning' do + subject(:payload) do + JSON.parse( + described_class.build_empty_feed_warning( + url: 'https://example.com/articles', + strategy: 'faraday', + site_title: 'Example Site' + ) + ) + end + + it 'uses updated channel description copy' do + expect(payload.fetch('description')).to include('We could not extract entries') + expect(payload.fetch('description')).not_to include('different parser') + end + + it 'uses updated item title and content text' do + first_item = payload.fetch('items').first + expect(first_item.fetch('title')).to eq('Preview unavailable for this source') + expect(first_item.fetch('content_text')).to include('What you can do:') + end + + it 'does not mention hidden strategy controls in item text' do + first_item = payload.fetch('items').first + expect(first_item.fetch('content_text')).not_to include('browserless strategy') + end + end +end diff --git a/spec/html2rss/web/sentry_logs_spec.rb b/spec/html2rss/web/sentry_logs_spec.rb index 27d5bb28..2e4a46eb 100644 --- a/spec/html2rss/web/sentry_logs_spec.rb +++ b/spec/html2rss/web/sentry_logs_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' require_relative '../../../app/web/config/runtime_env' +require_relative '../../../app/web/telemetry/app_logger' require_relative '../../../app/web/telemetry/sentry_logs' RSpec.describe Html2rss::Web::SentryLogs do @@ -23,6 +24,7 @@ let(:fake_sentry) do Module.new.tap do |mod| mod.define_singleton_method(:logger) { sentry_logger } + mod.define_singleton_method(:add_breadcrumb) { |**| nil } end end let(:raw_payload) do @@ -65,6 +67,22 @@ expect(captured_call).to eq({}) end + it 'adds breadcrumbs for request-critical structured logs even when sentry logs are disabled', :aggregate_failures do + stub_const('Sentry', fake_sentry) + allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: false) + allow(Sentry).to receive(:add_breadcrumb) + + Html2rss::Web::AppLogger.send( + :format_entry, + 'INFO', + Time.now.utc, + nil, + breadcrumb_payload.to_json + ) + + expect(Sentry).to have_received(:add_breadcrumb).with(expected_breadcrumb) + end + it 'falls back to info when an unsupported level is requested', :aggregate_failures do stub_const('Sentry', fake_sentry) allow(Html2rss::Web::RuntimeEnv).to receive_messages(sentry_enabled?: true, sentry_logs_enabled?: true) @@ -80,6 +98,44 @@ def build_sentry_logger logger_class.new(captured_call) end + def breadcrumb_payload + { + event_name: 'feed.create', + outcome: 'failure', + request_id: 'req-123', + route_group: 'api_v1', + strategy: 'faraday', + details: { url: 'https://example.com/articles', fallback: 'browserless' } + } + end + + def expected_breadcrumb + include( + category: 'feed.create', + message: 'feed.create', + level: 'info', + data: breadcrumb_data_matcher + ) + end + + def breadcrumb_data_matcher + include( + event_name: 'feed.create', + outcome: 'failure', + request_id: 'req-123', + route_group: 'api_v1', + strategy: 'faraday', + details: breadcrumb_details_matcher + ) + end + + def breadcrumb_details_matcher + include( + url: include(host: 'example.com', scheme: 'https'), + fallback: 'browserless' + ) + end + def expect_forwarded_payload expect(captured_call).to include(:message, :attributes) expect_forwarded_message diff --git a/spec/html2rss/web/xml_builder_spec.rb b/spec/html2rss/web/xml_builder_spec.rb new file mode 100644 index 00000000..a06d9047 --- /dev/null +++ b/spec/html2rss/web/xml_builder_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'nokogiri' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::XmlBuilder do + describe '.build_empty_feed_warning' do + subject(:xml_doc) do + xml = described_class.build_empty_feed_warning( + url: 'https://example.com/articles', + strategy: 'faraday', + site_title: 'Example Site' + ) + Nokogiri::XML(xml) + end + + it 'uses updated channel description copy' do + description = xml_doc.at_xpath('//channel/description').text + expect(description).to include('We could not extract entries') + expect(description).not_to include('different parser') + end + + it 'uses updated item title and content text' do + expect(xml_doc.at_xpath('//item/title').text).to eq('Preview unavailable for this source') + expect(xml_doc.at_xpath('//item/description').text).to include('What you can do:') + end + + it 'does not mention hidden strategy controls in item text' do + expect(xml_doc.at_xpath('//item/description').text).not_to include('browserless strategy') + end + end +end diff --git a/spec/support/api_contract_helpers.rb b/spec/support/api_contract_helpers.rb index 9b706b0d..60e8bd26 100644 --- a/spec/support/api_contract_helpers.rb +++ b/spec/support/api_contract_helpers.rb @@ -3,6 +3,13 @@ require 'json' module ApiContractHelpers + OPTIONAL_ERROR_FIELDS = { + kind: 'kind', + retryable: 'retryable', + next_action: 'next_action', + retry_action: 'retry_action' + }.freeze + def response_json(response) JSON.parse(response.body) end @@ -14,10 +21,13 @@ def expect_success_response(response) json end - def expect_error_response(response, code:) + def expect_error_response(response, code:, **expected) json = response_json(response) + error = json.fetch('error') expect(json['success']).to be(false) - expect(json.dig('error', 'code')).to eq(code) + expect(error.fetch('code')).to eq(code) + expect_optional_error_fields(error, expected) + expect_optional_next_strategy(error, expected) yield json if block_given? json end @@ -26,6 +36,7 @@ def expect_feed_payload(json) feed = json.fetch('data').fetch('feed') expect_feed_identifier_payload(feed) expect_feed_source_payload(feed) + expect(feed).not_to have_key('strategy') feed end @@ -37,7 +48,45 @@ def expect_feed_identifier_payload(feed) def expect_feed_source_payload(feed) expect(feed.fetch('url')).to be_a(String) - expect(feed.fetch('strategy')).to be_a(String) + expect(feed.fetch('name')).to be_a(String) + end + + def expect_conversion_payload(json, **expected) + conversion = json.fetch('data').fetch('conversion') + expect_required_conversion_fields(conversion, expected) + expect_optional_retry_state(conversion, expected) + conversion + end + + private + + def expect_optional_error_fields(error, expected) + OPTIONAL_ERROR_FIELDS.each do |key, field_name| + next unless expected.key?(key) + + expect(error.fetch(field_name)).to eq(expected[key]) + end + end + + def expect_optional_next_strategy(error, expected) + next_strategy = expected[:next_strategy] + if next_strategy.nil? + expect(error).not_to have_key('next_strategy') + else + expect(error.fetch('next_strategy')).to eq(next_strategy) + end + end + + def expect_required_conversion_fields(conversion, expected) + expect(conversion.fetch('readiness_phase')).to eq(expected.fetch(:readiness_phase)) + expect(conversion.fetch('preview_status')).to eq(expected.fetch(:preview_status)) + expect(conversion.fetch('warnings')).to eq(expected.fetch(:warnings, [])) + end + + def expect_optional_retry_state(conversion, expected) + return unless expected.key?(:retry_state) + + expect(conversion.fetch('retry')).to eq(expected[:retry_state]) end end diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 5705a7d5..d71c285e 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -157,21 +157,23 @@ normalized_paths[normalized][verb]['description'] ||= normalized_paths[normalized][verb]['summary'] - next unless normalized == '/feeds/{token}' - - normalized_paths[normalized][verb]['parameters'] ||= [] - has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter| - parameter['name'] == 'token' && parameter['in'] == 'path' - end - unless has_token_param - normalized_paths[normalized][verb]['parameters'] << { - 'name' => 'token', - 'in' => 'path', - 'required' => true, - 'schema' => { 'type' => 'string' } - } + if normalized.start_with?('/feeds/{token}') + normalized_paths[normalized][verb]['parameters'] ||= [] + has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter| + parameter['name'] == 'token' && parameter['in'] == 'path' + end + unless has_token_param + normalized_paths[normalized][verb]['parameters'] << { + 'name' => 'token', + 'in' => 'path', + 'required' => true, + 'schema' => { 'type' => 'string' } + } + end end + next unless normalized == '/feeds/{token}' + token_feed_error_statuses.each do |status| response = normalized_paths[normalized][verb].dig('responses', status) next unless response diff --git a/spec/support/shared_examples/api_error_contract_examples.rb b/spec/support/shared_examples/api_error_contract_examples.rb index 08bd1b40..98d0243f 100644 --- a/spec/support/shared_examples/api_error_contract_examples.rb +++ b/spec/support/shared_examples/api_error_contract_examples.rb @@ -1,12 +1,21 @@ # frozen_string_literal: true -RSpec.shared_examples 'api error contract' do |status:, code:, message: nil| - it "returns #{status} with #{code} error payload", :aggregate_failures do +RSpec.shared_examples 'api error contract' do |expected| + it "returns #{expected.fetch(:status)} with #{expected.fetch(:code)} error payload", :aggregate_failures do # rubocop:disable RSpec/ExampleLength perform_request.call - expect(last_response.status).to eq(status) + expect(last_response.status).to eq(expected.fetch(:status)) expect(last_response.content_type).to include('application/json') - json = expect_error_response(last_response, code: code) - expect(json.dig('error', 'message')).to eq(message) if message + json = expect_error_response( + last_response, + code: expected.fetch(:code), + kind: expected.fetch(:kind), + retryable: expected.fetch(:retryable), + next_action: expected.fetch(:next_action), + retry_action: expected.fetch(:retry_action, 'none'), + next_strategy: expected[:next_strategy] + ) + expected_message = expected[:message] + expect(json.dig('error', 'message')).to eq(expected_message) if expected_message end end