1- import { describe , it , expect } from 'vitest' ;
1+ import { describe , it , expect , beforeEach } 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' , ( ) => {
@@ -11,20 +11,15 @@ describe('App contract', () => {
1111 globalThis . history . replaceState ( { } , '' , 'http://localhost:3000/create' ) ;
1212 globalThis . localStorage . clear ( ) ;
1313 globalThis . sessionStorage . clear ( ) ;
14- } ) ;
15-
16- const authenticate = ( ) => {
1714 globalThis . sessionStorage . setItem ( 'html2rss_access_token' , token ) ;
18- } ;
19-
20- it ( 'shows feed result when API responds with success' , async ( ) => {
21- authenticate ( ) ;
15+ } ) ;
2216
17+ it ( 'shows feed result when the API returns structured create and status payloads' , async ( ) => {
2318 server . use (
2419 http . post ( '/api/v1/feeds' , async ( { request } ) => {
25- const body = ( await request . json ( ) ) as { url : string ; strategy : string } ;
20+ const body = ( await request . json ( ) ) as { url : string } ;
2621
27- expect ( body ) . toEqual ( { url : 'https://example.com/articles' , strategy : 'faraday' } ) ;
22+ expect ( body ) . toEqual ( { url : 'https://example.com/articles' } ) ;
2823 expect ( request . headers . get ( 'authorization' ) ) . toBe ( `Bearer ${ token } ` ) ;
2924
3025 return HttpResponse . json (
@@ -33,9 +28,29 @@ describe('App contract', () => {
3328 feed_token : 'generated-token' ,
3429 public_url : '/api/v1/feeds/generated-token' ,
3530 json_public_url : '/api/v1/feeds/generated-token.json' ,
36- } )
31+ conversion : {
32+ readiness_phase : 'link_created' ,
33+ preview_status : 'pending' ,
34+ warnings : [ ] ,
35+ } ,
36+ } ) ,
37+ { status : 201 }
3738 ) ;
3839 } ) ,
40+ http . get ( '/api/v1/feeds/generated-token/status' , ( ) =>
41+ HttpResponse . json (
42+ buildFeedResponse ( {
43+ feed_token : 'generated-token' ,
44+ public_url : '/api/v1/feeds/generated-token' ,
45+ json_public_url : '/api/v1/feeds/generated-token.json' ,
46+ conversion : {
47+ readiness_phase : 'feed_ready' ,
48+ preview_status : 'ready' ,
49+ warnings : [ ] ,
50+ } ,
51+ } )
52+ )
53+ ) ,
3954 http . get ( '/api/v1/feeds/generated-token.json' , ( { request } ) => {
4055 expect ( request . headers . get ( 'accept' ) ) . toBe ( 'application/feed+json' ) ;
4156
@@ -60,101 +75,47 @@ describe('App contract', () => {
6075 render ( < App /> ) ;
6176
6277 await waitFor ( ( ) => {
63- expect ( screen . getByRole ( 'button' , { name : 'Generate feed URL' } ) ) . toBeEnabled ( ) ;
78+ expect ( screen . getByLabelText ( 'Page URL') ) . toBeInTheDocument ( ) ;
6479 } ) ;
6580 expect ( screen . queryByRole ( 'combobox' ) ) . not . toBeInTheDocument ( ) ;
6681
6782 const urlInput = screen . getByLabelText ( 'Page URL' ) as HTMLInputElement ;
6883 fireEvent . input ( urlInput , { target : { value : 'https://example.com/articles' } } ) ;
69-
7084 fireEvent . click ( screen . getByRole ( 'button' , { name : 'Generate feed URL' } ) ) ;
7185
72- await waitFor (
73- ( ) => {
74- expect ( screen . getByText ( 'Feed created' ) ) . toBeInTheDocument ( ) ;
75- expect ( screen . getByText ( 'Example Feed' ) ) . toBeInTheDocument ( ) ;
76- expect ( document . querySelector ( '.result-shell' ) ) . toHaveAttribute ( 'data-state' , 'warming' ) ;
77- expect ( screen . getByLabelText ( 'Feed URL' ) ) . toBeInTheDocument ( ) ;
78- expect ( screen . getByRole ( 'button' , { name : 'Copy feed URL' } ) ) . toBeInTheDocument ( ) ;
79- expect ( screen . getByRole ( 'button' , { name : 'Create another feed' } ) ) . toBeInTheDocument ( ) ;
80- expect ( screen . getByText ( 'Preview' ) ) . toBeInTheDocument ( ) ;
81- expect ( screen . getByText ( 'Latest items from this feed' ) ) . toBeInTheDocument ( ) ;
82- } ,
83- { timeout : 3000 }
84- ) ;
85- } ) ;
86-
87- it ( 'loads instance metadata from /api/v1 without trailing slash' , async ( ) => {
88- let slashlessMetadataRequests = 0 ;
89- let trailingSlashMetadataRequests = 0 ;
90-
91- server . use (
92- http . get ( '/api/v1' , ( ) => {
93- slashlessMetadataRequests += 1 ;
94-
95- return HttpResponse . json ( {
96- success : true ,
97- data : {
98- api : {
99- name : 'html2rss-web API' ,
100- description : 'RESTful API for converting websites to RSS feeds' ,
101- openapi_url : 'http://example.test/openapi.yaml' ,
102- } ,
103- instance : {
104- feed_creation : {
105- enabled : true ,
106- access_token_required : true ,
107- } ,
108- featured_feeds : [ ] ,
109- } ,
110- } ,
111- } ) ;
112- } ) ,
113- http . get ( '/api/v1/' , ( ) => {
114- trailingSlashMetadataRequests += 1 ;
115-
116- return HttpResponse . text ( '' , { status : 404 } ) ;
117- } )
118- ) ;
119-
120- render ( < App /> ) ;
121-
122- await screen . findByLabelText ( 'Page URL' ) ;
123-
124- expect ( screen . getByRole ( 'button' , { name : 'Generate feed URL' } ) ) . toBeInTheDocument ( ) ;
125- expect ( screen . queryByText ( 'Instance metadata unavailable' ) ) . not . toBeInTheDocument ( ) ;
126- expect ( slashlessMetadataRequests ) . toBeGreaterThanOrEqual ( 1 ) ;
127- expect ( trailingSlashMetadataRequests ) . toBe ( 0 ) ;
128- } ) ;
129-
130- it ( 'shows the metadata unavailable notice when /api/v1 responds with non-JSON content' , async ( ) => {
131- server . use (
132- http . get ( '/api/v1' , ( ) => HttpResponse . text ( 'not-json' , { status : 502 } ) ) ,
133- http . get ( '/api/v1/' , ( ) => HttpResponse . text ( '' , { status : 404 } ) )
134- ) ;
135-
136- render ( < App /> ) ;
137-
138- await screen . findByText ( 'Instance metadata unavailable' ) ;
139-
140- expect ( screen . getByText ( 'Invalid response format from API metadata' ) ) . toBeInTheDocument ( ) ;
86+ await waitFor ( ( ) => {
87+ expect ( screen . getByText ( 'Feed ready' ) ) . toBeInTheDocument ( ) ;
88+ expect ( screen . getByText ( 'Example Feed' ) ) . toBeInTheDocument ( ) ;
89+ expect ( document . querySelector ( '.result-shell' ) ) . toHaveAttribute ( 'data-state' , 'ready' ) ;
90+ expect ( screen . getByLabelText ( 'Feed URL' ) ) . toBeInTheDocument ( ) ;
91+ expect ( screen . getByRole ( 'button' , { name : 'Copy feed URL' } ) ) . toBeInTheDocument ( ) ;
92+ expect ( screen . getByRole ( 'button' , { name : 'Create another feed' } ) ) . toBeInTheDocument ( ) ;
93+ expect ( screen . getByText ( 'Latest items from this feed' ) ) . toBeInTheDocument ( ) ;
94+ } ) ;
14195 } ) ;
14296
143- it ( 'reopens token recovery when a saved token is rejected by /api/v1/feeds' , async ( ) => {
144- authenticate ( ) ;
145-
97+ it ( 'reopens token recovery when a saved token is rejected by structured auth metadata' , async ( ) => {
14698 server . use (
14799 http . post ( '/api/v1/feeds' , async ( ) =>
148- HttpResponse . json ( { success : false , error : { message : 'Unauthorized' } } , { status : 401 } )
100+ HttpResponse . json (
101+ buildStructuredErrorResponse ( {
102+ code : 'UNAUTHORIZED' ,
103+ message : 'Authentication required' ,
104+ kind : 'auth' ,
105+ retryable : false ,
106+ next_action : 'enter_token' ,
107+ retry_action : 'none' ,
108+ } ) ,
109+ { status : 401 }
110+ )
149111 )
150112 ) ;
151113
152114 render ( < App /> ) ;
153115
154116 await waitFor ( ( ) => {
155- expect ( screen . getByRole ( 'button' , { name : 'Generate feed URL' } ) ) . toBeEnabled ( ) ;
117+ expect ( screen . getByLabelText ( 'Page URL') ) . toBeInTheDocument ( ) ;
156118 } ) ;
157- expect ( screen . queryByRole ( 'combobox' ) ) . not . toBeInTheDocument ( ) ;
158119
159120 fireEvent . input ( screen . getByLabelText ( 'Page URL' ) , {
160121 target : { value : 'https://example.com/articles' } ,
0 commit comments