Improvements to Flutter client (#1042)

* Chat improvements

* Delete/reset account via API for Flutter app

* Fix tests.

* Add "contact us" to settings

* Update mobile/lib/screens/chat_conversation_screen.dart

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Improve LLM special token detection

* Deactivated user shouldn't have API working

* Fix tests

* API-Key usage

* Flutter app launch failure on no network

* Handle deletion/reset delays

* Local cached data may become stale

* Use X-Api-Key correctly!

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Juan José Mata
2026-02-22 21:22:32 -05:00
committed by GitHub
parent b1b2793e43
commit ad3087f1dd
15 changed files with 716 additions and 53 deletions

View File

@@ -73,6 +73,11 @@ class Api::V1::BaseController < ApplicationController
render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized)
return false
end
unless @current_user.active?
render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized)
return false
end
else
Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id"
render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized)
@@ -96,6 +101,11 @@ class Api::V1::BaseController < ApplicationController
return false unless @api_key && @api_key.active?
@current_user = @api_key.user
unless @current_user.active?
render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized)
return false
end
@api_key.update_last_used!
@authentication_method = :api_key
@rate_limiter = ApiRateLimiter.limit(@api_key)

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class Api::V1::UsersController < Api::V1::BaseController
before_action :ensure_write_scope
def reset
FamilyResetJob.perform_later(Current.family)
render json: { message: "Account reset has been initiated" }
end
def destroy
user = current_resource_owner
if user.deactivate
Current.session&.destroy
render json: { message: "Account has been deleted" }
else
render json: { error: "Failed to delete account", details: user.errors.full_messages }, status: :unprocessable_entity
end
end
private
def ensure_write_scope
authorize_scope!(:write)
end
end

View File

@@ -394,6 +394,9 @@ Rails.application.routes.draw do
end
end
delete "users/reset", to: "users#reset"
delete "users/me", to: "users#destroy"
# Test routes for API controller testing (only available in test environment)
if Rails.env.test?
get "test", to: "test#index"

View File

@@ -53,6 +53,17 @@ class Chat {
};
}
static const String defaultTitle = 'New Chat';
static const int maxTitleLength = 80;
static String generateTitle(String prompt) {
final trimmed = prompt.trim();
if (trimmed.length <= maxTitleLength) return trimmed;
return trimmed.substring(0, maxTitleLength);
}
bool get hasDefaultTitle => title == defaultTitle;
Chat copyWith({
String? id,
String? title,

View File

@@ -1,6 +1,32 @@
import 'tool_call.dart';
class Message {
/// Known LLM special tokens that may leak into responses (strip from display).
/// Includes ASCII ChatML (<|...|>) and DeepSeek full-width variants (<...>).
static const _llmTokenPatterns = [
'<|start_of_sentence|>',
'<|im_start|>',
'<|im_end|>',
'<|endoftext|>',
'</s>',
// DeepSeek full-width pipe variants (U+FF5C )
'<\uFF5Cstart_of_sentence\uFF5C>',
'<\uFF5Cim_start\uFF5C>',
'<\uFF5Cim_end\uFF5C>',
'<\uFF5Cendoftext\uFF5C>',
];
/// Removes LLM tokens and trims trailing whitespace from assistant content.
static String sanitizeContent(String content) {
var out = content;
for (final token in _llmTokenPatterns) {
out = out.replaceAll(token, '');
}
out = out.replaceAll(RegExp(r'<\|[^|]*\|>'), '');
out = out.replaceAll(RegExp('<\u{FF5C}[^\u{FF5C}]*\u{FF5C}>'), '');
return out.trim();
}
final String id;
final String type;
final String role;
@@ -22,11 +48,14 @@ class Message {
});
factory Message.fromJson(Map<String, dynamic> json) {
final rawContent = json['content'] as String;
final role = json['role'] as String;
final content = role == 'assistant' ? sanitizeContent(rawContent) : rawContent;
return Message(
id: json['id'].toString(),
type: json['type'] as String,
role: json['role'] as String,
content: json['content'] as String,
role: role,
content: content,
model: json['model'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),

View File

@@ -1,3 +1,4 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/user.dart';
@@ -57,9 +58,20 @@ class AuthProvider with ChangeNotifier {
_tokens = await _authService.getStoredTokens();
_user = await _authService.getStoredUser();
// If tokens exist but are expired, try to refresh
// If tokens exist but are expired, try to refresh only when online
if (_tokens != null && _tokens!.isExpired) {
await _refreshToken();
final results = await Connectivity().checkConnectivity();
final isOnline = results.any((r) =>
r == ConnectivityResult.mobile ||
r == ConnectivityResult.wifi ||
r == ConnectivityResult.ethernet ||
r == ConnectivityResult.vpn ||
r == ConnectivityResult.bluetooth);
if (isOnline) {
await _refreshToken();
} else {
await logout();
}
}
}
} catch (e) {

View File

@@ -14,6 +14,10 @@ class ChatProvider with ChangeNotifier {
String? _errorMessage;
Timer? _pollingTimer;
/// Content length of the last assistant message from the previous poll.
/// Used to detect when the LLM has finished writing (no growth between polls).
int? _lastAssistantContentLength;
List<Chat> get chats => _chats;
Chat? get currentChat => _currentChat;
bool get isLoading => _isLoading;
@@ -85,7 +89,6 @@ class ChatProvider with ChangeNotifier {
required String accessToken,
String? title,
String? initialMessage,
String model = 'gpt-4',
}) async {
_isLoading = true;
_errorMessage = null;
@@ -96,7 +99,6 @@ class ChatProvider with ChangeNotifier {
accessToken: accessToken,
title: title,
initialMessage: initialMessage,
model: model,
);
if (result['success'] == true) {
@@ -127,8 +129,9 @@ class ChatProvider with ChangeNotifier {
}
}
/// Send a message to the current chat
Future<void> sendMessage({
/// Send a message to the current chat.
/// Returns true if delivery succeeded, false otherwise.
Future<bool> sendMessage({
required String accessToken,
required String chatId,
required String content,
@@ -158,11 +161,14 @@ class ChatProvider with ChangeNotifier {
// Start polling for AI response
_startPolling(accessToken, chatId);
return true;
} else {
_errorMessage = result['error'] ?? 'Failed to send message';
return false;
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
return false;
} finally {
_isSendingMessage = false;
notifyListeners();
@@ -239,6 +245,7 @@ class ChatProvider with ChangeNotifier {
/// Start polling for new messages (AI responses)
void _startPolling(String accessToken, String chatId) {
_stopPolling();
_lastAssistantContentLength = null;
_pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
await _pollForUpdates(accessToken, chatId);
@@ -262,26 +269,55 @@ class ChatProvider with ChangeNotifier {
if (result['success'] == true) {
final updatedChat = result['chat'] as Chat;
// Check if we have new messages
if (_currentChat != null && _currentChat!.id == chatId) {
final oldMessageCount = _currentChat!.messages.length;
final newMessageCount = updatedChat.messages.length;
if (_currentChat == null || _currentChat!.id != chatId) return;
if (newMessageCount > oldMessageCount) {
_currentChat = updatedChat;
notifyListeners();
final oldMessages = _currentChat!.messages;
final newMessages = updatedChat.messages;
final oldMessageCount = oldMessages.length;
final newMessageCount = newMessages.length;
// Check if the last message is from assistant and complete
final lastMessage = updatedChat.messages.lastOrNull;
if (lastMessage != null && lastMessage.isAssistant) {
// Stop polling after getting assistant response
_stopPolling();
final oldContentLengthById = <String, int>{};
for (final m in oldMessages) {
if (m.isAssistant) oldContentLengthById[m.id] = m.content.length;
}
bool shouldUpdate = false;
// New messages added
if (newMessageCount > oldMessageCount) {
shouldUpdate = true;
_lastAssistantContentLength = null;
} else if (newMessageCount == oldMessageCount) {
// Same count: check if any assistant message has more content
for (final m in newMessages) {
if (m.isAssistant) {
final oldLen = oldContentLengthById[m.id] ?? 0;
if (m.content.length > oldLen) {
shouldUpdate = true;
break;
}
}
}
}
if (shouldUpdate) {
_currentChat = updatedChat;
notifyListeners();
}
final lastMessage = updatedChat.messages.lastOrNull;
if (lastMessage != null && lastMessage.isAssistant) {
final newLen = lastMessage.content.length;
if (newLen > (_lastAssistantContentLength ?? 0)) {
_lastAssistantContentLength = newLen;
} else {
// Content stable: no growth since last poll
_stopPolling();
_lastAssistantContentLength = null;
}
}
}
} catch (e) {
// Silently fail polling errors to avoid interrupting user experience
debugPrint('Polling error: ${e.toString()}');
}
}

View File

@@ -1,9 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../models/chat.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../models/message.dart';
class _SendMessageIntent extends Intent {
const _SendMessageIntent();
}
class ChatConversationScreen extends StatefulWidget {
final String chatId;
@@ -69,15 +75,24 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
return;
}
// Clear input field immediately
final shouldUpdateTitle = chatProvider.currentChat?.hasDefaultTitle == true;
_messageController.clear();
await chatProvider.sendMessage(
final delivered = await chatProvider.sendMessage(
accessToken: accessToken,
chatId: widget.chatId,
content: content,
);
if (delivered && shouldUpdateTitle) {
await chatProvider.updateChatTitle(
accessToken: accessToken,
chatId: widget.chatId,
title: Chat.generateTitle(content),
);
}
// Scroll to bottom after sending
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
@@ -298,34 +313,48 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
),
],
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
child: Shortcuts(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): _SendMessageIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
onInvoke: (_) {
if (!chatProvider.isSendingMessage) _sendMessage();
return null;
},
),
},
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textCapitalization: TextCapitalization.sentences,
),
),
maxLines: null,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => _sendMessage(),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: chatProvider.isSendingMessage ? null : _sendMessage,
color: colorScheme.primary,
iconSize: 28,
),
],
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: chatProvider.isSendingMessage ? null : _sendMessage,
color: colorScheme.primary,
iconSize: 28,
),
],
),
),
),
],

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/chat.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import 'chat_conversation_screen.dart';
@@ -58,7 +59,7 @@ class _ChatListScreenState extends State<ChatListScreen> {
final chat = await chatProvider.createChat(
accessToken: accessToken,
title: 'New Chat',
title: Chat.defaultTitle,
);
// Close loading dialog

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../providers/auth_provider.dart';
import '../services/offline_storage_service.dart';
import '../services/log_service.dart';
import '../services/preferences_service.dart';
import '../services/user_service.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -16,6 +18,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _groupByType = false;
String? _appVersion;
bool _isResettingAccount = false;
bool _isDeletingAccount = false;
@override
void initState() {
@@ -101,6 +105,139 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _launchContactUrl(BuildContext context) async {
final uri = Uri.parse('https://discord.com/invite/36ZGBsxYEK');
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to open link')),
);
}
}
Future<void> _handleResetAccount(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Account'),
content: const Text(
'Resetting your account will delete all your accounts, categories, '
'merchants, tags, and other data, but keep your user account intact.\n\n'
'This action cannot be undone. Are you sure?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Reset Account'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
setState(() => _isResettingAccount = true);
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
await authProvider.logout();
return;
}
final result = await UserService().resetAccount(accessToken: accessToken);
if (!context.mounted) return;
if (result['success'] == true) {
await OfflineStorageService().clearAllData();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account reset has been initiated. This may take a moment.'),
backgroundColor: Colors.green,
),
);
await authProvider.logout();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['error'] ?? 'Failed to reset account'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isResettingAccount = false);
}
}
Future<void> _handleDeleteAccount(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Account'),
content: const Text(
'Deleting your account will permanently remove all your data '
'and cannot be undone.\n\n'
'Are you sure you want to delete your account?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete Account'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
setState(() => _isDeletingAccount = true);
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
await authProvider.logout();
return;
}
final result = await UserService().deleteAccount(accessToken: accessToken);
if (!context.mounted) return;
if (result['success'] == true) {
await authProvider.logout();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['error'] ?? 'Failed to delete account'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isDeletingAccount = false);
}
}
Future<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -200,6 +337,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Contact us'),
subtitle: Text(
'https://discord.com/invite/36ZGBsxYEK',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
onTap: () => _launchContactUrl(context),
),
const Divider(),
// Display Settings Section
@@ -253,6 +403,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(),
// Danger Zone Section
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Danger Zone',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.red),
title: const Text('Reset Account'),
subtitle: const Text(
'Delete all accounts, categories, merchants, and tags but keep your user account',
),
trailing: _isResettingAccount
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: null,
enabled: !_isResettingAccount && !_isDeletingAccount,
onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context),
),
ListTile(
leading: const Icon(Icons.delete_forever, color: Colors.red),
title: const Text('Delete Account'),
subtitle: const Text(
'Permanently remove all your data. This cannot be undone.',
),
trailing: _isDeletingAccount
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: null,
enabled: !_isDeletingAccount && !_isResettingAccount,
onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context),
),
const Divider(),
// Sign out button
Padding(
padding: const EdgeInsets.all(16),

View File

@@ -118,14 +118,11 @@ class ChatService {
required String accessToken,
String? title,
String? initialMessage,
String model = 'gpt-4',
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats');
final body = <String, dynamic>{
'model': model,
};
final body = <String, dynamic>{};
if (title != null) {
body['title'] = title;

View File

@@ -0,0 +1,71 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api_config.dart';
class UserService {
Future<Map<String, dynamic>> resetAccount({
required String accessToken,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/users/reset');
final response = await http.delete(
url,
headers: ApiConfig.getAuthHeaders(accessToken),
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return {'success': true};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'Session expired. Please login again.',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to reset account',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
Future<Map<String, dynamic>> deleteAccount({
required String accessToken,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/users/me');
final response = await http.delete(
url,
headers: ApiConfig.getAuthHeaders(accessToken),
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return {'success': true};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'Session expired. Please login again.',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to delete account',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
}

View File

@@ -0,0 +1,123 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'API V1 Users', type: :request do
let(:family) do
Family.create!(
name: 'API Family',
currency: 'USD',
locale: 'en',
date_format: '%m-%d-%Y'
)
end
let(:user) do
family.users.create!(
email: 'api-user@example.com',
password: 'password123',
password_confirmation: 'password123'
)
end
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: 'API Docs Key',
key: key,
scopes: %w[read_write],
source: 'web'
)
end
let(:'X-Api-Key') { api_key.plain_key }
path '/api/v1/users/reset' do
delete 'Reset account' do
tags 'Users'
description 'Resets all financial data (accounts, categories, merchants, tags, etc.) ' \
'for the current user\'s family while keeping the user account intact. ' \
'The reset runs asynchronously in the background.'
security [ { apiKeyAuth: [] } ]
produces 'application/json'
response '200', 'account reset initiated' do
schema '$ref' => '#/components/schemas/SuccessMessage'
run_test!
end
response '401', 'unauthorized' do
let(:'X-Api-Key') { 'invalid-key' }
run_test!
end
response '403', 'insufficient scope' do
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: 'Read Only Key',
key: key,
scopes: %w[read],
source: 'web'
)
end
run_test!
end
end
end
path '/api/v1/users/me' do
delete 'Delete account' do
tags 'Users'
description 'Permanently deactivates the current user account and all associated data. ' \
'This action cannot be undone.'
security [ { apiKeyAuth: [] } ]
produces 'application/json'
response '200', 'account deleted' do
schema '$ref' => '#/components/schemas/SuccessMessage'
run_test!
end
response '401', 'unauthorized' do
let(:'X-Api-Key') { 'invalid-key' }
run_test!
end
response '403', 'insufficient scope' do
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: 'Read Only Key',
key: key,
scopes: %w[read],
source: 'web'
)
end
run_test!
end
response '422', 'deactivation failed' do
schema '$ref' => '#/components/schemas/ErrorResponse'
before do
allow_any_instance_of(User).to receive(:deactivate).and_return(false)
allow_any_instance_of(User).to receive(:errors).and_return(
double(full_messages: [ 'Cannot deactivate admin with other users' ])
)
end
run_test!
end
end
end
end

View File

@@ -517,6 +517,13 @@ RSpec.configure do |config|
},
pagination: { '$ref' => '#/components/schemas/Pagination' }
}
},
SuccessMessage: {
type: :object,
required: %w[message],
properties: {
message: { type: :string }
}
}
}
}

View File

@@ -0,0 +1,116 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}"
)
@read_only_api_key = ApiKey.create!(
user: @user,
name: "Test Read-Only Key",
scopes: [ "read" ],
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile"
)
end
# -- Authentication --------------------------------------------------------
test "reset requires authentication" do
delete "/api/v1/users/reset"
assert_response :unauthorized
end
test "destroy requires authentication" do
delete "/api/v1/users/me"
assert_response :unauthorized
end
# -- Scope enforcement -----------------------------------------------------
test "reset requires write scope" do
delete "/api/v1/users/reset", headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
test "destroy requires write scope" do
delete "/api/v1/users/me", headers: api_headers(@read_only_api_key)
assert_response :forbidden
end
# -- Reset -----------------------------------------------------------------
test "reset enqueues FamilyResetJob and returns 200" do
assert_enqueued_with(job: FamilyResetJob) do
delete "/api/v1/users/reset", headers: api_headers(@api_key)
end
assert_response :ok
body = JSON.parse(response.body)
assert_equal "Account reset has been initiated", body["message"]
end
# -- Delete account --------------------------------------------------------
test "destroy deactivates user and returns 200" do
solo_family = Family.create!(name: "Solo Family", currency: "USD", locale: "en", date_format: "%m-%d-%Y")
solo_user = solo_family.users.create!(
email: "solo@example.com",
password: "password123",
password_confirmation: "password123",
role: :admin
)
solo_api_key = ApiKey.create!(
user: solo_user,
name: "Solo Key",
scopes: [ "read_write" ],
display_key: "test_solo_#{SecureRandom.hex(8)}"
)
delete "/api/v1/users/me", headers: api_headers(solo_api_key)
assert_response :ok
body = JSON.parse(response.body)
assert_equal "Account has been deleted", body["message"]
solo_user.reload
assert_not solo_user.active?
assert_not_equal "solo@example.com", solo_user.email
end
test "destroy returns 422 when admin has other family members" do
delete "/api/v1/users/me", headers: api_headers(@api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Failed to delete account", body["error"]
end
# -- Deactivated user ------------------------------------------------------
test "rejects deactivated user with 401" do
@user.update_column(:active, false)
delete "/api/v1/users/reset", headers: api_headers(@api_key)
assert_response :unauthorized
body = JSON.parse(response.body)
assert_equal "Account has been deactivated", body["message"]
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.display_key }
end
end