diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index 590b332ee..81683d2ba 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -54,14 +54,7 @@ module Api return end - render json: token_response.merge( - user: { - id: user.id, - email: user.email, - first_name: user.first_name, - last_name: user.last_name - } - ), status: :created + render json: token_response.merge(user: mobile_user_payload(user)), status: :created else render json: { errors: user.errors.full_messages }, status: :unprocessable_entity end @@ -97,14 +90,7 @@ module Api return end - render json: token_response.merge( - user: { - id: user.id, - email: user.email, - first_name: user.first_name, - last_name: user.last_name - } - ) + render json: token_response.merge(user: mobile_user_payload(user)) else render json: { error: "Invalid email or password" }, status: :unauthorized end @@ -143,7 +129,9 @@ module Api id: cached[:user_id], email: cached[:user_email], first_name: cached[:user_first_name], - last_name: cached[:user_last_name] + last_name: cached[:user_last_name], + ui_layout: cached[:user_ui_layout], + ai_enabled: cached[:user_ai_enabled] } } end @@ -229,6 +217,17 @@ module Api def sso_exchange_params params.require(:code) end + + def mobile_user_payload(user) + { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + ui_layout: user.ui_layout, + ai_enabled: user.ai_enabled? + } + end end end end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 000000000..054d5461d --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,28 @@ +module Api + module V1 + class UsersController < BaseController + def enable_ai + user = current_resource_owner + + if user.update(ai_enabled: true) + render json: { user: mobile_user_payload(user) } + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def mobile_user_payload(user) + { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + ui_layout: user.ui_layout, + ai_enabled: user.ai_enabled? + } + end + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 009a45eab..ec53ef5f6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -260,7 +260,9 @@ class SessionsController < ApplicationController user_id: user.id, user_email: user.email, user_first_name: user.first_name, - user_last_name: user.last_name + user_last_name: user.last_name, + user_ui_layout: user.ui_layout, + user_ai_enabled: user.ai_enabled? ), expires_in: 5.minutes ) diff --git a/config/routes.rb b/config/routes.rb index eb1055ba6..202a27aef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -372,6 +372,7 @@ Rails.application.routes.draw do post "auth/login", to: "auth#login" post "auth/refresh", to: "auth#refresh" post "auth/sso_exchange", to: "auth#sso_exchange" + patch "user/enable_ai", to: "users#enable_ai" # Production API endpoints resources :accounts, only: [ :index, :show ] diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9417f1e97..fce347b77 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -798,10 +798,10 @@ components: format: date qty: type: string - description: Quantity as string (JSON number or string from API) + description: Quantity of shares held price: type: string - description: Price as string (JSON number or string from API) + description: Formatted price per share amount: type: string currency: @@ -875,6 +875,323 @@ paths: application/json: schema: "$ref": "#/components/schemas/AccountCollection" + "/api/v1/auth/signup": + post: + summary: Sign up a new user + tags: + - Auth + parameters: [] + responses: + '201': + description: user created + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: invite code required or invalid + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + email: + type: string + format: email + description: User email address + password: + type: string + description: Password (min 8 chars, mixed case, number, special + char) + first_name: + type: string + last_name: + type: string + required: + - email + - password + device: + type: object + properties: + device_id: + type: string + description: Unique device identifier + device_name: + type: string + description: Human-readable device name + device_type: + type: string + description: Device type (e.g. ios, android) + os_version: + type: string + app_version: + type: string + required: + - device_id + - device_name + - device_type + - os_version + - app_version + invite_code: + type: string + nullable: true + description: Invite code (required when invites are enforced) + required: + - user + - device + required: true + "/api/v1/auth/login": + post: + summary: Log in with email and password + tags: + - Auth + parameters: [] + responses: + '200': + description: login successful + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '401': + description: invalid credentials or MFA required + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + password: + type: string + otp_code: + type: string + nullable: true + description: TOTP code if MFA is enabled + device: + type: object + properties: + device_id: + type: string + device_name: + type: string + device_type: + type: string + os_version: + type: string + app_version: + type: string + required: + - device_id + - device_name + - device_type + - os_version + - app_version + required: + - email + - password + - device + required: true + "/api/v1/auth/sso_exchange": + post: + summary: Exchange mobile SSO authorization code for tokens + tags: + - Auth + description: Exchanges a one-time authorization code (received via deep link + after mobile SSO) for OAuth tokens. The code is single-use and expires after + 5 minutes. + parameters: [] + responses: + '200': + description: tokens issued + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '401': + description: invalid or expired code + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: One-time authorization code from mobile SSO callback + required: + - code + required: true + "/api/v1/auth/refresh": + post: + summary: Refresh an access token + tags: + - Auth + parameters: [] + responses: + '200': + description: token refreshed + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + '401': + description: invalid refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '400': + description: missing refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + description: The refresh token from a previous login or refresh + device: + type: object + properties: + device_id: + type: string + required: + - device_id + required: + - refresh_token + - device + required: true "/api/v1/categories": get: summary: List categories @@ -1247,8 +1564,8 @@ paths: parameters: - name: id in: path - description: Holding ID required: true + description: Holding ID schema: type: string get: @@ -1732,8 +2049,8 @@ paths: parameters: - name: id in: path - description: Trade ID required: true + description: Trade ID schema: type: string get: @@ -1767,6 +2084,7 @@ paths: - Trades security: - apiKeyAuth: [] + parameters: [] responses: '200': description: trade updated @@ -1780,12 +2098,6 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" - '422': - description: validation error - content: - application/json: - schema: - "$ref": "#/components/schemas/ErrorResponse" requestBody: content: application/json: @@ -1794,34 +2106,30 @@ paths: properties: trade: type: object - description: Flat params; controller builds internal structure. When qty/price are updated, type or nature controls sign; if omitted, existing trade direction is preserved. properties: date: type: string format: date - name: - type: string - amount: + qty: + type: number + price: type: number - currency: - type: string - notes: - type: string - nature: - type: string - enum: - - inflow - - outflow type: type: string enum: - buy - sell - description: Determines sign when qty/price are updated. - qty: - type: number - price: - type: number + nature: + type: string + enum: + - inflow + - outflow + name: + type: string + notes: + type: string + currency: + type: string investment_activity_label: type: string category_id: @@ -2136,6 +2444,8 @@ paths: items: type: string format: uuid + description: Array of tag IDs to assign. Omit to preserve existing + tags; use [] to clear all tags. required: true delete: summary: Delete a transaction @@ -2156,6 +2466,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/user/enable_ai": + patch: + summary: Enable AI features for the authenticated user + tags: + - Users + security: + - apiKeyAuth: [] + responses: + '200': + description: ai enabled + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + nullable: true + last_name: + type: string + nullable: true + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/valuations": post: summary: Create valuation diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart index e932bcda3..ccc871d20 100644 --- a/mobile/lib/models/user.dart +++ b/mobile/lib/models/user.dart @@ -3,23 +3,60 @@ class User { final String email; final String? firstName; final String? lastName; + final String uiLayout; + final bool aiEnabled; User({ required this.id, required this.email, this.firstName, this.lastName, + required this.uiLayout, + required this.aiEnabled, }); + bool get isIntroLayout => uiLayout == 'intro'; + factory User.fromJson(Map json) { return User( id: json['id'].toString(), email: json['email'] as String, firstName: json['first_name'] as String?, lastName: json['last_name'] as String?, + uiLayout: (json['ui_layout'] as String?) ?? 'dashboard', + aiEnabled: json['ai_enabled'] == true, ); } + User copyWith({ + String? id, + String? email, + String? firstName, + String? lastName, + String? uiLayout, + bool? aiEnabled, + }) { + return User( + id: id ?? this.id, + email: email ?? this.email, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + uiLayout: uiLayout ?? this.uiLayout, + aiEnabled: aiEnabled ?? this.aiEnabled, + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'first_name': firstName, + 'last_name': lastName, + 'ui_layout': uiLayout, + 'ai_enabled': aiEnabled, + }; + } + String get displayName { if (firstName != null && lastName != null) { return '$firstName $lastName'; diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 0fa47fd63..15cf3dca1 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -22,6 +22,8 @@ class AuthProvider with ChangeNotifier { bool _showMfaInput = false; // Track if we should show MFA input field User? get user => _user; + bool get isIntroLayout => _user?.isIntroLayout ?? false; + bool get aiEnabled => _user?.aiEnabled ?? true; AuthTokens? get tokens => _tokens; bool get isLoading => _isLoading; bool get isInitializing => _isInitializing; // Expose initialization state @@ -321,6 +323,28 @@ class AuthProvider with ChangeNotifier { return _tokens?.accessToken; } + + Future enableAi() async { + final accessToken = await getValidAccessToken(); + if (accessToken == null) { + _errorMessage = 'Session expired. Please login again.'; + notifyListeners(); + return false; + } + + final result = await _authService.enableAi(accessToken: accessToken); + if (result['success'] == true) { + _user = result['user'] as User?; + _errorMessage = null; + notifyListeners(); + return true; + } + + _errorMessage = result['error'] as String?; + notifyListeners(); + return false; + } + void clearError() { _errorMessage = null; notifyListeners(); diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart index 8d002851f..7b537667e 100644 --- a/mobile/lib/screens/main_navigation_screen.dart +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; import 'dashboard_screen.dart'; import 'chat_list_screen.dart'; import 'more_screen.dart'; @@ -15,60 +17,147 @@ class _MainNavigationScreenState extends State { int _currentIndex = 0; final _dashboardKey = GlobalKey(); - late final List _screens; + List _buildScreens(bool introLayout) { + final screens = []; - @override - void initState() { - super.initState(); - _screens = [ - DashboardScreen(key: _dashboardKey), - const ChatListScreen(), - const MoreScreen(), - const SettingsScreen(), - ]; + if (!introLayout) { + screens.add(DashboardScreen(key: _dashboardKey)); + } + + screens.add(const ChatListScreen()); + + if (!introLayout) { + screens.add(const MoreScreen()); + } + + screens.add(const SettingsScreen()); + + return screens; } - @override - Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack( - index: _currentIndex, - children: _screens, + List _buildDestinations(bool introLayout) { + final destinations = []; + + if (!introLayout) { + destinations.add( + const NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + ); + } + + destinations.add( + const NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'AI Chat', ), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: (index) { - setState(() { - _currentIndex = index; - }); - // Reload preferences whenever switching back to dashboard - if (index == 0) { - _dashboardKey.currentState?.reloadPreferences(); - } - }, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', + ); + + if (!introLayout) { + destinations.add( + const NavigationDestination( + icon: Icon(Icons.more_horiz), + selectedIcon: Icon(Icons.more_horiz), + label: 'More', + ), + ); + } + + destinations.add( + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ); + + return destinations; + } + + Future _showEnableAiPrompt() async { + final authProvider = Provider.of(context, listen: false); + + final shouldEnable = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Turn on AI Chat?'), + content: const Text('AI Chat is currently disabled in your account settings. Would you like to turn it on now?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Not now'), ), - NavigationDestination( - icon: Icon(Icons.chat_bubble_outline), - selectedIcon: Icon(Icons.chat_bubble), - label: 'AI Chat', - ), - NavigationDestination( - icon: Icon(Icons.more_horiz), - selectedIcon: Icon(Icons.more_horiz), - label: 'More', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Turn on AI'), ), ], ), ); + + if (shouldEnable != true) { + return false; + } + + final enabled = await authProvider.enableAi(); + + if (!enabled && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(authProvider.errorMessage ?? 'Unable to enable AI right now.'), + backgroundColor: Colors.red, + ), + ); + } + + return enabled; + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, authProvider, _) { + final introLayout = authProvider.isIntroLayout; + final screens = _buildScreens(introLayout); + final destinations = _buildDestinations(introLayout); + + if (_currentIndex >= screens.length) { + _currentIndex = 0; + } + + final chatIndex = introLayout ? 0 : 1; + final homeIndex = 0; + + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: screens, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) async { + if (index == chatIndex && !authProvider.aiEnabled) { + final enabled = await _showEnableAiPrompt(); + if (!enabled) { + return; + } + } + + setState(() { + _currentIndex = index; + }); + + if (!introLayout && index == homeIndex) { + _dashboardKey.currentState?.reloadPreferences(); + } + }, + destinations: destinations, + ), + ); + }, + ); } } diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index c6b6c8859..eb2f6a890 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -435,6 +435,43 @@ class AuthService { } } + + Future> enableAi({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/user/enable_ai'); + final response = await http.patch( + url, + headers: { + ...ApiConfig.getAuthHeaders(accessToken), + 'Content-Type': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final user = User.fromJson(responseData['user']); + await _saveUser(user); + return { + 'success': true, + 'user': user, + }; + } + + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Failed to enable AI', + }; + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + Future logout() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _userKey); @@ -474,12 +511,7 @@ class AuthService { Future _saveUser(User user) async { await _storage.write( key: _userKey, - value: jsonEncode({ - 'id': user.id, - 'email': user.email, - 'first_name': user.firstName, - 'last_name': user.lastName, - }), + value: jsonEncode(user.toJson()), ); } diff --git a/spec/requests/api/v1/auth_spec.rb b/spec/requests/api/v1/auth_spec.rb index 38f797bd1..ce2aac828 100644 --- a/spec/requests/api/v1/auth_spec.rb +++ b/spec/requests/api/v1/auth_spec.rb @@ -51,7 +51,9 @@ RSpec.describe 'API V1 Auth', type: :request do id: { type: :string, format: :uuid }, email: { type: :string }, first_name: { type: :string }, - last_name: { type: :string } + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } } } } @@ -110,7 +112,9 @@ RSpec.describe 'API V1 Auth', type: :request do id: { type: :string, format: :uuid }, email: { type: :string }, first_name: { type: :string }, - last_name: { type: :string } + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } } } } @@ -152,7 +156,9 @@ RSpec.describe 'API V1 Auth', type: :request do id: { type: :string, format: :uuid }, email: { type: :string }, first_name: { type: :string }, - last_name: { type: :string } + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } } } } @@ -209,4 +215,6 @@ RSpec.describe 'API V1 Auth', type: :request do end end end + + end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 000000000..40153ddbd --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Users', type: :request do + path '/api/v1/user/enable_ai' do + patch 'Enable AI features for the authenticated user' do + tags 'Users' + consumes 'application/json' + produces 'application/json' + security [{ apiKeyAuth: [] }] + + response '200', 'ai enabled' do + schema type: :object, + properties: { + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string, nullable: true }, + last_name: { type: :string, nullable: true }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end +end diff --git a/test/controllers/api/v1/auth_controller_test.rb b/test/controllers/api/v1/auth_controller_test.rb index 043f18098..42fdc4e35 100644 --- a/test/controllers/api/v1/auth_controller_test.rb +++ b/test/controllers/api/v1/auth_controller_test.rb @@ -49,6 +49,9 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest assert_equal "newuser@example.com", response_data["user"]["email"] assert_equal "New", response_data["user"]["first_name"] assert_equal "User", response_data["user"]["last_name"] + new_user = User.find(response_data["user"]["id"]) + assert_equal new_user.ui_layout, response_data["user"]["ui_layout"] + assert_equal new_user.ai_enabled?, response_data["user"]["ai_enabled"] # OAuth token assertions assert response_data["access_token"].present? @@ -58,8 +61,8 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest assert response_data["created_at"].present? # Verify the device was created - new_user = User.find(response_data["user"]["id"]) - device = new_user.mobile_devices.first + created_user = User.find(response_data["user"]["id"]) + device = created_user.mobile_devices.first assert_equal @device_info[:device_id], device.device_id assert_equal @device_info[:device_name], device.device_name assert_equal @device_info[:device_type], device.device_type @@ -227,6 +230,8 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest assert_equal user.id.to_s, response_data["user"]["id"] assert_equal user.email, response_data["user"]["email"] + assert_equal user.ui_layout, response_data["user"]["ui_layout"] + assert_equal user.ai_enabled?, response_data["user"]["ai_enabled"] # OAuth token assertions assert response_data["access_token"].present? @@ -439,4 +444,6 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest response_data = JSON.parse(response.body) assert_equal "Refresh token is required", response_data["error"] end + + end diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb new file mode 100644 index 000000000..d1682137c --- /dev/null +++ b/test/controllers/api/v1/users_controller_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @user.update!(ai_enabled: false) + + @shared_app = Doorkeeper::Application.find_or_create_by!(name: "Sure Mobile") do |app| + app.redirect_uri = "sureapp://oauth/callback" + app.scopes = "read_write" + app.confidential = false + end + + @token = Doorkeeper::AccessToken.create!( + application: @shared_app, + resource_owner_id: @user.id, + scopes: "read_write" + ) + end + + test "should enable ai for authenticated user" do + patch "/api/v1/user/enable_ai", headers: { + "Authorization" => "Bearer #{@token.token}", + "Content-Type" => "application/json" + } + + assert_response :success + + response_data = JSON.parse(response.body) + assert_equal true, response_data.dig("user", "ai_enabled") + assert_equal @user.ui_layout, response_data.dig("user", "ui_layout") + assert @user.reload.ai_enabled? + end + + test "should require authentication when enabling ai" do + patch "/api/v1/user/enable_ai", headers: { "Content-Type" => "application/json" } + + assert_response :unauthorized + end +end