@@ -25,6 +25,7 @@ afterEach(() => {
2525 clearSqliteCompletionsCache ( ) ;
2626 clearSqliteRoutineStateCache ( ) ;
2727 vi . useRealTimers ( ) ;
28+ vi . unstubAllGlobals ( ) ;
2829} ) ;
2930
3031function readLS < T > ( key : string , fallback : T ) : T {
@@ -74,34 +75,109 @@ describe("create_habit", () => {
7475 } ) ;
7576} ) ;
7677
78+ // ws-10: create_transaction — async/server tool (`ASYNC_CHAT_ACTION_NAMES`).
79+ // Витрати йдуть через `POST /api/finyk/manual-expenses`; offline-fallback та
80+ // доходи лишаються на legacy LS-шляху. Тому всі тести — через `executeActions`.
7781describe ( "create_transaction" , ( ) => {
78- it ( "записує витрату в finyk_manual_expenses_v1" , ( ) => {
79- const msg = executeAction ( {
80- name : "create_transaction" ,
81- input : { amount : 150 , category : "food" , description : "кава" } ,
82- } ) ;
83- expect ( msg ) . toContain ( "Витрату" ) ;
84- expect ( msg ) . toContain ( "150" ) ;
82+ function stubFetchReject ( ) : void {
83+ vi . stubGlobal (
84+ "fetch" ,
85+ vi . fn ( async ( ) => {
86+ throw new TypeError ( "Failed to fetch" ) ;
87+ } ) ,
88+ ) ;
89+ }
90+
91+ afterEach ( ( ) => {
92+ vi . unstubAllGlobals ( ) ;
93+ } ) ;
94+
95+ it ( "пише витрату через сервер і дзеркалить у finyk_manual_expenses_v1" , async ( ) => {
96+ const serverExpense = {
97+ id : "0b7e6c3a-7e0f-4b59-9b39-2f4f7f6f9d11" ,
98+ amountKopiykas : 15000 ,
99+ category : "food" ,
100+ date : "2024-06-15" ,
101+ note : "кава" ,
102+ createdAt : "2024-06-15T12:00:00.000Z" ,
103+ updatedAt : "2024-06-15T12:00:00.000Z" ,
104+ } ;
105+ vi . stubGlobal (
106+ "fetch" ,
107+ vi . fn (
108+ async ( ) =>
109+ new Response ( JSON . stringify ( { ok : true , expense : serverExpense } ) , {
110+ status : 201 ,
111+ headers : { "content-type" : "application/json" } ,
112+ } ) ,
113+ ) ,
114+ ) ;
115+
116+ const [ out ] = await executeActions ( [
117+ {
118+ name : "create_transaction" ,
119+ input : { amount : 150 , category : "food" , description : "кава" } ,
120+ } ,
121+ ] ) ;
122+ expect ( out ! . result ) . toContain ( "Витрату" ) ;
123+ expect ( out ! . result ) . toContain ( "150" ) ;
124+ expect ( out ! . result ) . toContain ( "записано на сервері" ) ;
125+ expect ( out ! . result ) . toContain ( serverExpense . id ) ;
126+ // Server-шлях не дає undo — DELETE-ендпоінта немає.
127+ expect ( out ! . undo ) . toBeUndefined ( ) ;
128+
85129 const arr = readLS <
86130 Array < {
131+ id : string ;
87132 amount : number ;
88133 category : string ;
89134 description : string ;
90135 type : string ;
91136 } >
92137 > ( "finyk_manual_expenses_v1" , [ ] ) ;
93138 expect ( arr ) . toHaveLength ( 1 ) ;
139+ // LS-дзеркало: id серверний (UUID), amount у гривнях (legacy LS-shape).
140+ expect ( arr [ 0 ] ! . id ) . toBe ( serverExpense . id ) ;
94141 expect ( arr [ 0 ] ! . amount ) . toBe ( 150 ) ;
95142 expect ( arr [ 0 ] ! . category ) . toBe ( "food" ) ;
96143 expect ( arr [ 0 ] ! . type ) . toBe ( "expense" ) ;
97144 } ) ;
98145
99- it ( "записує дохід коли type='income'" , ( ) => {
100- const msg = executeAction ( {
101- name : "create_transaction" ,
102- input : { type : "income" , amount : 5000 } ,
103- } ) ;
104- expect ( msg ) . toContain ( "Дохід" ) ;
146+ it ( "fallback: пише локально з undo, коли сервер недоступний" , async ( ) => {
147+ stubFetchReject ( ) ;
148+ const [ out ] = await executeActions ( [
149+ {
150+ name : "create_transaction" ,
151+ input : { amount : 150 , category : "food" , description : "кава" } ,
152+ } ,
153+ ] ) ;
154+ expect ( out ! . result ) . toContain ( "Витрату" ) ;
155+ expect ( out ! . result ) . toContain ( "150" ) ;
156+ expect ( out ! . result ) . toContain ( "записано лише локально" ) ;
157+ expect ( typeof out ! . undo ) . toBe ( "function" ) ;
158+
159+ const arr = readLS < Array < { id : string ; amount : number ; type : string } > > (
160+ "finyk_manual_expenses_v1" ,
161+ [ ] ,
162+ ) ;
163+ expect ( arr ) . toHaveLength ( 1 ) ;
164+ expect ( arr [ 0 ] ! . id ) . toMatch ( / ^ m _ / ) ;
165+ expect ( arr [ 0 ] ! . amount ) . toBe ( 150 ) ;
166+ expect ( arr [ 0 ] ! . type ) . toBe ( "expense" ) ;
167+ } ) ;
168+
169+ it ( "записує дохід локально коли type='income' (сервер приймає лише витрати)" , async ( ) => {
170+ stubFetchReject ( ) ;
171+ const fetchMock = globalThis . fetch as ReturnType < typeof vi . fn > ;
172+ const [ out ] = await executeActions ( [
173+ {
174+ name : "create_transaction" ,
175+ input : { type : "income" , amount : 5000 } ,
176+ } ,
177+ ] ) ;
178+ expect ( out ! . result ) . toContain ( "Дохід" ) ;
179+ // Income не має бити в API взагалі.
180+ expect ( fetchMock ) . not . toHaveBeenCalled ( ) ;
105181 const arr = readLS < Array < { type : string ; amount : number } > > (
106182 "finyk_manual_expenses_v1" ,
107183 [ ] ,
@@ -110,21 +186,26 @@ describe("create_transaction", () => {
110186 expect ( arr [ 0 ] ! . amount ) . toBe ( 5000 ) ;
111187 } ) ;
112188
113- it ( "відмовляє на 0 або від'ємну суму" , ( ) => {
114- expect (
115- executeAction ( {
116- name : "create_transaction" ,
117- input : { amount : 0 } ,
118- } ) ,
119- ) . toContain ( "Некоректна" ) ;
120- expect (
121- executeAction ( {
122- name : "create_transaction" ,
123- input : { amount : - 5 } ,
124- } ) ,
125- ) . toContain ( "Некоректна" ) ;
189+ it ( "відмовляє на 0 або від'ємну суму без серверного виклику" , async ( ) => {
190+ stubFetchReject ( ) ;
191+ const fetchMock = globalThis . fetch as ReturnType < typeof vi . fn > ;
192+ const results = await executeActions ( [
193+ { name : "create_transaction" , input : { amount : 0 } } ,
194+ { name : "create_transaction" , input : { amount : - 5 } } ,
195+ ] ) ;
196+ expect ( results [ 0 ] ! . result ) . toContain ( "Некоректна" ) ;
197+ expect ( results [ 1 ] ! . result ) . toContain ( "Некоректна" ) ;
198+ expect ( fetchMock ) . not . toHaveBeenCalled ( ) ;
126199 expect ( localStorage . getItem ( "finyk_manual_expenses_v1" ) ) . toBeNull ( ) ;
127200 } ) ;
201+
202+ it ( "sync executeAction відмовляє з інструкцією про async-шлях" , ( ) => {
203+ const msg = executeAction ( {
204+ name : "create_transaction" ,
205+ input : { amount : 150 } ,
206+ } ) ;
207+ expect ( msg ) . toContain ( "вимагає async" ) ;
208+ } ) ;
128209} ) ;
129210
130211describe ( "log_set" , ( ) => {
@@ -231,6 +312,14 @@ describe("log_water", () => {
231312
232313describe ( "executeActions — паралельне виконання" , ( ) => {
233314 it ( "повертає результати у тому ж порядку, що й input" , async ( ) => {
315+ // create_transaction — async/server tool: глушимо fetch, щоб тест
316+ // детерміновано пішов offline-fallback-шляхом без реальної мережі.
317+ vi . stubGlobal (
318+ "fetch" ,
319+ vi . fn ( async ( ) => {
320+ throw new TypeError ( "Failed to fetch" ) ;
321+ } ) ,
322+ ) ;
234323 const results = await executeActions ( [
235324 { name : "create_habit" , input : { name : "Пити воду" } } ,
236325 {
0 commit comments