1- import { describe , it , expect } from 'vitest' ;
1+ import { describe , it , expect , beforeEach , vi } from 'vitest' ;
22import { render , screen , fireEvent , waitFor } from '@testing-library/preact' ;
33import { http , HttpResponse } from 'msw' ;
4- import { server , buildFeedResponse } from './mocks/server' ;
4+ import { server , buildFeedResponse , buildStructuredErrorResponse } from './mocks/server' ;
55import { App } from '../components/App' ;
66
77describe ( 'App contract' , ( ) => {
88 const token = 'contract-token' ;
99
10- const authenticate = ( ) => {
11- globalThis . localStorage . setItem ( 'html2rss_access_token' , token ) ;
12- } ;
10+ beforeEach ( ( ) => {
11+ globalThis . history . replaceState ( { } , '' , 'http://localhost:3000/#/create' ) ;
12+ globalThis . localStorage . clear ( ) ;
13+ globalThis . sessionStorage . clear ( ) ;
14+ globalThis . sessionStorage . setItem ( 'html2rss_access_token' , token ) ;
15+ } ) ;
16+
17+ it ( 'shows feed result when the API returns structured create payload and preview feed' , async ( ) => {
18+ const nativeFetch = globalThis . fetch ;
19+ const fetchSpy = vi . spyOn ( globalThis , 'fetch' ) . mockImplementation ( ( input , init ) => {
20+ if ( String ( input ) . endsWith ( '/api/v1/feeds/generated-token.json' ) ) {
21+ expect ( ( init ?. headers as Record < string , string > | undefined ) ?. Accept ) . toBe ( 'application/feed+json' ) ;
22+ return Promise . resolve (
23+ new Response (
24+ JSON . stringify ( {
25+ items : [
26+ {
27+ title : 'Contract Item' ,
28+ content_text : 'Contract preview excerpt.' ,
29+ url : 'https://example.com/contract-item' ,
30+ date_published : '2024-01-01T00:00:00Z' ,
31+ } ,
32+ ] ,
33+ } ) ,
34+ { status : 200 , headers : { 'Content-Type' : 'application/feed+json' } }
35+ )
36+ ) ;
37+ }
1338
14- it ( 'shows feed result when API responds with success' , async ( ) => {
15- authenticate ( ) ;
39+ return nativeFetch ( input , init ) ;
40+ } ) ;
1641
1742 server . use (
1843 http . post ( '/api/v1/feeds' , async ( { request } ) => {
@@ -27,10 +52,11 @@ describe('App contract', () => {
2752 feed_token : 'generated-token' ,
2853 public_url : '/api/v1/feeds/generated-token' ,
2954 json_public_url : '/api/v1/feeds/generated-token.json' ,
30- } )
55+ } ) ,
56+ { status : 201 }
3157 ) ;
3258 } ) ,
33- http . get ( '/api/v1/feeds/generated-token.json' , ( { request } ) => {
59+ http . get ( 'http://localhost:3000 /api/v1/feeds/generated-token.json' , ( { request } ) => {
3460 expect ( request . headers . get ( 'accept' ) ) . toBe ( 'application/feed+json' ) ;
3561
3662 return HttpResponse . json (
@@ -48,108 +74,67 @@ describe('App contract', () => {
4874 headers : { 'content-type' : 'application/feed+json' } ,
4975 }
5076 ) ;
77+ } ) ,
78+ http . get ( '/api/v1/feeds/generated-token.json' , ( { request } ) => {
79+ expect ( request . headers . get ( 'accept' ) ) . toBe ( 'application/feed+json' ) ;
80+
81+ return HttpResponse . json ( {
82+ items : [
83+ {
84+ title : 'Contract Item' ,
85+ content_text : 'Contract preview excerpt.' ,
86+ url : 'https://example.com/contract-item' ,
87+ date_published : '2024-01-01T00:00:00Z' ,
88+ } ,
89+ ] ,
90+ } ) ;
5191 } )
5292 ) ;
5393
5494 render ( < App /> ) ;
5595
56- await screen . findByLabelText ( 'Page URL' ) ;
5796 await waitFor ( ( ) => {
58- expect ( screen . getByRole ( 'combobox ') ) . toHaveValue ( 'faraday' ) ;
97+ expect ( screen . getByLabelText ( 'Page URL ') ) . toBeInTheDocument ( ) ;
5998 } ) ;
99+ expect ( screen . queryByRole ( 'combobox' ) ) . not . toBeInTheDocument ( ) ;
60100
61101 const urlInput = screen . getByLabelText ( 'Page URL' ) as HTMLInputElement ;
62102 fireEvent . input ( urlInput , { target : { value : 'https://example.com/articles' } } ) ;
63-
64103 fireEvent . click ( screen . getByRole ( 'button' , { name : 'Generate feed URL' } ) ) ;
65104
66105 await waitFor ( ( ) => {
67106 expect ( screen . getByText ( 'Feed ready' ) ) . toBeInTheDocument ( ) ;
68107 expect ( screen . getByText ( 'Example Feed' ) ) . toBeInTheDocument ( ) ;
108+ expect ( document . querySelector ( '.result-shell' ) ) . toHaveAttribute ( 'data-state' , 'result' ) ;
69109 expect ( screen . getByLabelText ( 'Feed URL' ) ) . toBeInTheDocument ( ) ;
70110 expect ( screen . getByRole ( 'button' , { name : 'Copy feed URL' } ) ) . toBeInTheDocument ( ) ;
71- expect ( screen . getByRole ( 'link' , { name : 'Open feed' } ) ) . toBeInTheDocument ( ) ;
72- expect ( screen . getByRole ( 'link' , { name : 'Open JSON Feed' } ) ) . toHaveAttribute (
73- 'href' ,
74- 'http://localhost:3000/api/v1/feeds/generated-token.json'
75- ) ;
76111 expect ( screen . getByRole ( 'button' , { name : 'Create another feed' } ) ) . toBeInTheDocument ( ) ;
77- expect ( screen . getByText ( 'Preview' ) ) . toBeInTheDocument ( ) ;
78112 expect ( screen . getByText ( 'Latest items from this feed' ) ) . toBeInTheDocument ( ) ;
79- expect ( screen . getByText ( 'Contract Item' ) ) . toBeInTheDocument ( ) ;
80113 } ) ;
114+ fetchSpy . mockRestore ( ) ;
81115 } ) ;
82116
83- it ( 'loads instance metadata from /api/v1 without trailing slash' , async ( ) => {
84- let slashlessMetadataRequests = 0 ;
85- let trailingSlashMetadataRequests = 0 ;
86-
87- server . use (
88- http . get ( '/api/v1' , ( ) => {
89- slashlessMetadataRequests += 1 ;
90-
91- return HttpResponse . json ( {
92- success : true ,
93- data : {
94- api : {
95- name : 'html2rss-web API' ,
96- description : 'RESTful API for converting websites to RSS feeds' ,
97- openapi_url : 'http://example.test/openapi.yaml' ,
98- } ,
99- instance : {
100- feed_creation : {
101- enabled : true ,
102- access_token_required : true ,
103- } ,
104- featured_feeds : [ ] ,
105- } ,
106- } ,
107- } ) ;
108- } ) ,
109- http . get ( '/api/v1/' , ( ) => {
110- trailingSlashMetadataRequests += 1 ;
111-
112- return HttpResponse . text ( '' , { status : 404 } ) ;
113- } )
114- ) ;
115-
116- render ( < App /> ) ;
117-
118- await screen . findByLabelText ( 'Page URL' ) ;
119-
120- expect ( screen . getByRole ( 'button' , { name : 'Generate feed URL' } ) ) . toBeInTheDocument ( ) ;
121- expect ( screen . queryByText ( 'Instance metadata unavailable' ) ) . not . toBeInTheDocument ( ) ;
122- expect ( slashlessMetadataRequests ) . toBeGreaterThanOrEqual ( 1 ) ;
123- expect ( trailingSlashMetadataRequests ) . toBe ( 0 ) ;
124- } ) ;
125-
126- it ( 'shows the metadata unavailable notice when /api/v1 responds with non-JSON content' , async ( ) => {
127- server . use (
128- http . get ( '/api/v1' , ( ) => HttpResponse . text ( 'not-json' , { status : 502 } ) ) ,
129- http . get ( '/api/v1/' , ( ) => HttpResponse . text ( '' , { status : 404 } ) )
130- ) ;
131-
132- render ( < App /> ) ;
133-
134- await screen . findByText ( 'Instance metadata unavailable' ) ;
135-
136- expect ( screen . getByText ( 'Invalid response format from API metadata' ) ) . toBeInTheDocument ( ) ;
137- } ) ;
138-
139- it ( 'reopens token recovery when a saved token is rejected by /api/v1/feeds' , async ( ) => {
140- authenticate ( ) ;
141-
117+ it ( 'reopens token recovery when a saved token is rejected by structured auth metadata' , async ( ) => {
142118 server . use (
143119 http . post ( '/api/v1/feeds' , async ( ) =>
144- HttpResponse . json ( { success : false , error : { message : 'Unauthorized' } } , { status : 401 } )
120+ HttpResponse . json (
121+ buildStructuredErrorResponse ( {
122+ code : 'UNAUTHORIZED' ,
123+ message : 'Authentication required' ,
124+ kind : 'auth' ,
125+ retryable : false ,
126+ next_action : 'enter_token' ,
127+ retry_action : 'none' ,
128+ } ) ,
129+ { status : 401 }
130+ )
145131 )
146132 ) ;
147133
148134 render ( < App /> ) ;
149135
150- await screen . findByLabelText ( 'Page URL' ) ;
151136 await waitFor ( ( ) => {
152- expect ( screen . getByRole ( 'combobox ') ) . toHaveValue ( 'faraday' ) ;
137+ expect ( screen . getByLabelText ( 'Page URL ') ) . toBeInTheDocument ( ) ;
153138 } ) ;
154139
155140 fireEvent . input ( screen . getByLabelText ( 'Page URL' ) , {
@@ -160,7 +145,7 @@ describe('App contract', () => {
160145 await screen . findByText ( 'Access token was rejected. Paste a valid token to continue.' ) ;
161146
162147 expect ( screen . getByText ( 'Enter access token' ) ) . toBeInTheDocument ( ) ;
163- expect ( screen . queryByText ( 'Could not create feed link' ) ) . not . toBeInTheDocument ( ) ;
164- expect ( globalThis . localStorage . getItem ( 'html2rss_access_token' ) ) . toBeNull ( ) ;
148+ expect ( screen . queryByText ( "Couldn't create feed yet" ) ) . not . toBeInTheDocument ( ) ;
149+ expect ( globalThis . sessionStorage . getItem ( 'html2rss_access_token' ) ) . toBeNull ( ) ;
165150 } ) ;
166151} ) ;
0 commit comments