44
55use App \Ai \Agents \SqlQueryAssistant ;
66use App \Jobs \ProcessDataExportJob ;
7+ use App \Models \AiMemoryProfile ;
78use App \Models \ConnectionPermission ;
89use App \Models \DatabaseConnection ;
910use App \Models \Permission ;
@@ -136,10 +137,134 @@ public function test_ai_generate_returns_structured_payload_with_confirmation()
136137 'explanation ' ,
137138 'tables_used ' ,
138139 'confidence ' ,
140+ 'conversation_id ' ,
141+ 'dialect ' ,
142+ 'memory_applied ' => ['short_term ' , 'long_term ' ],
139143 'requires_confirmation ' ,
140144 'suggested_visualization ' => ['type ' , 'x_axis ' , 'y_axis ' , 'reason ' ],
141145 ]);
142146 $ response ->assertJsonPath ('requires_confirmation ' , true );
147+ $ response ->assertJsonPath ('dialect ' , 'pgsql ' );
148+ $ response ->assertJsonPath ('memory_applied.short_term ' , true );
149+ $ response ->assertJsonPath ('conversation_id ' , fn (mixed $ value ): bool => is_string ($ value ) && $ value !== '' );
150+
151+ $ profile = AiMemoryProfile::query ()
152+ ->where ('user_id ' , $ user ->id )
153+ ->where ('connection_id ' , $ connection ->id )
154+ ->first ();
155+
156+ $ this ->assertNotNull ($ profile );
157+ $ this ->assertIsArray ($ profile ?->preferred_tables);
158+ }
159+
160+ public function test_ai_generate_continues_existing_conversation_for_same_user ()
161+ {
162+ SqlQueryAssistant::fake ([
163+ [
164+ 'sql ' => 'SELECT * FROM customers LIMIT 10 ' ,
165+ 'explanation ' => 'First response. ' ,
166+ 'tables_used ' => ['customers ' ],
167+ 'confidence ' => 'high ' ,
168+ 'suggested_visualization ' => [
169+ 'type ' => 'table ' ,
170+ 'x_axis ' => null ,
171+ 'y_axis ' => null ,
172+ 'reason ' => 'Rows are tabular. ' ,
173+ ],
174+ ],
175+ [
176+ 'sql ' => 'SELECT COUNT(*) AS total FROM customers ' ,
177+ 'explanation ' => 'Second response. ' ,
178+ 'tables_used ' => ['customers ' ],
179+ 'confidence ' => 'high ' ,
180+ 'suggested_visualization ' => [
181+ 'type ' => 'kpi ' ,
182+ 'x_axis ' => null ,
183+ 'y_axis ' => 'total ' ,
184+ 'reason ' => 'Single metric. ' ,
185+ ],
186+ ],
187+ ]);
188+
189+ $ user = $ this ->createUserWithPermission ('queries.ai_generate ' );
190+ $ connection = $ this ->createConnection ();
191+ $ this ->grantTableAccess ($ user , $ connection , 'customers ' );
192+
193+ $ first = $ this ->actingAs ($ user )->postJson ('/queries/ai-generate ' , [
194+ 'connection_id ' => $ connection ->id ,
195+ 'question ' => 'List customers ' ,
196+ 'selected_tables ' => ['customers ' ],
197+ ]);
198+
199+ $ first ->assertOk ();
200+ $ conversationId = $ first ->json ('conversation_id ' );
201+ $ this ->assertIsString ($ conversationId );
202+
203+ $ second = $ this ->actingAs ($ user )->postJson ('/queries/ai-generate ' , [
204+ 'connection_id ' => $ connection ->id ,
205+ 'question ' => 'Now count them ' ,
206+ 'conversation_id ' => $ conversationId ,
207+ 'selected_tables ' => ['customers ' ],
208+ ]);
209+
210+ $ second ->assertOk ();
211+ $ second ->assertJsonPath ('conversation_id ' , $ conversationId );
212+ }
213+
214+ public function test_ai_generate_rejects_conversation_id_from_another_user ()
215+ {
216+ SqlQueryAssistant::fake ([
217+ [
218+ 'sql ' => 'SELECT * FROM customers LIMIT 10 ' ,
219+ 'explanation ' => 'First response. ' ,
220+ 'tables_used ' => ['customers ' ],
221+ 'confidence ' => 'high ' ,
222+ 'suggested_visualization ' => [
223+ 'type ' => 'table ' ,
224+ 'x_axis ' => null ,
225+ 'y_axis ' => null ,
226+ 'reason ' => 'Rows are tabular. ' ,
227+ ],
228+ ],
229+ [
230+ 'sql ' => 'SELECT * FROM customers LIMIT 5 ' ,
231+ 'explanation ' => 'Other user response. ' ,
232+ 'tables_used ' => ['customers ' ],
233+ 'confidence ' => 'medium ' ,
234+ 'suggested_visualization ' => [
235+ 'type ' => 'table ' ,
236+ 'x_axis ' => null ,
237+ 'y_axis ' => null ,
238+ 'reason ' => 'Rows are tabular. ' ,
239+ ],
240+ ],
241+ ]);
242+
243+ $ owner = $ this ->createUserWithPermission ('queries.ai_generate ' );
244+ $ intruder = $ this ->createUserWithPermission ('queries.ai_generate ' );
245+ $ connection = $ this ->createConnection ();
246+ $ this ->grantTableAccess ($ owner , $ connection , 'customers ' );
247+ $ this ->grantTableAccess ($ intruder , $ connection , 'customers ' );
248+
249+ $ first = $ this ->actingAs ($ owner )->postJson ('/queries/ai-generate ' , [
250+ 'connection_id ' => $ connection ->id ,
251+ 'question ' => 'List customers ' ,
252+ 'selected_tables ' => ['customers ' ],
253+ ]);
254+
255+ $ first ->assertOk ();
256+ $ conversationId = $ first ->json ('conversation_id ' );
257+ $ this ->assertIsString ($ conversationId );
258+
259+ $ rejected = $ this ->actingAs ($ intruder )->postJson ('/queries/ai-generate ' , [
260+ 'connection_id ' => $ connection ->id ,
261+ 'question ' => 'Reuse that conversation ' ,
262+ 'conversation_id ' => $ conversationId ,
263+ 'selected_tables ' => ['customers ' ],
264+ ]);
265+
266+ $ rejected ->assertStatus (422 );
267+ $ rejected ->assertJsonPath ('message ' , 'The provided conversation_id is invalid for this user. ' );
143268 }
144269
145270 public function test_exports_can_be_queued_in_xlsx ()
@@ -201,6 +326,25 @@ public function test_query_execute_returns_sanitized_message_on_engine_error()
201326 $ response ->assertJsonPath ('message ' , 'The SQL engine returned a sanitized error response. ' );
202327 }
203328
329+ public function test_query_execute_blocks_dialect_mismatch_for_mysql_connection ()
330+ {
331+ $ user = $ this ->createUserWithPermission ('queries.execute ' );
332+ $ connection = $ this ->createConnection (driver: 'mysql ' );
333+ $ this ->grantTableAccess ($ user , $ connection , 'customers ' );
334+
335+ $ response = $ this ->actingAs ($ user )->postJson ('/queries/execute ' , [
336+ 'connection_id ' => $ connection ->id ,
337+ 'sql ' => "SELECT * FROM customers WHERE name ILIKE '%john%' " ,
338+ ]);
339+
340+ $ response ->assertStatus (422 );
341+ $ response ->assertJsonPath ('message ' , 'SQL validation failed. ' );
342+ $ this ->assertTrue (
343+ collect ($ response ->json ('errors ' , []))
344+ ->contains (fn (mixed $ error ): bool => is_string ($ error ) && str_contains ($ error , 'Dialect mismatch ' ))
345+ );
346+ }
347+
204348 private function createUserWithPermission (string $ permissionKey ): User
205349 {
206350 $ permission = Permission::query ()->firstOrCreate ([
@@ -221,13 +365,15 @@ private function createUserWithPermission(string $permissionKey): User
221365 return $ user ;
222366 }
223367
224- private function createConnection (): DatabaseConnection
368+ private function createConnection (string $ driver = ' pgsql ' ): DatabaseConnection
225369 {
370+ $ defaultPort = $ driver === 'pgsql ' ? 5432 : 3306 ;
371+
226372 return DatabaseConnection::query ()->create ([
227373 'name ' => 'Local PG ' ,
228- 'driver ' => ' pgsql ' ,
374+ 'driver ' => $ driver ,
229375 'host ' => '127.0.0.1 ' ,
230- 'port ' => 5432 ,
376+ 'port ' => $ defaultPort ,
231377 'database ' => 'demo ' ,
232378 'username ' => 'readonly ' ,
233379 'password ' => 'secret ' ,
0 commit comments