format and logs
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions

This commit is contained in:
Dmitry 2026-01-08 16:02:47 +03:00
parent feeced1543
commit 4ed1839893
54 changed files with 2055 additions and 1913 deletions

View file

@ -965,3 +965,4 @@ void main() {

View file

@ -281,3 +281,4 @@ Built with build_runner/jit in 1s; wrote 0 outputs.

View file

@ -321,3 +321,4 @@ import 'package:mnemo_cards_backend/database/database.dart';

View file

@ -30,3 +30,4 @@ GROUP BY is_blacklisted;

View file

@ -35,7 +35,9 @@ class PaymentManager {
/// Создать платеж в базе данных
Future<PaymentDto> createPayment(PaymentDto paymentDto, String userId) async {
print('🔍 PaymentManager.createPayment: Starting');
print('🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}');
print(
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
);
final companion = paymentDto.toCompanion(userId);
print('🔍 PaymentManager.createPayment: Calling paymentDao.createPayment');
final paymentId = await _db.paymentDao.createPayment(companion);
@ -43,7 +45,9 @@ class PaymentManager {
print('🔍 PaymentManager.createPayment: Getting payment by id');
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) {
print('❌ PaymentManager.createPayment: Payment not found after creation, id=$paymentId');
print(
'❌ PaymentManager.createPayment: Payment not found after creation, id=$paymentId',
);
throw Exception('Failed to create payment');
}
print('✅ PaymentManager.createPayment: Payment retrieved successfully');
@ -299,7 +303,7 @@ class PaymentManager {
try {
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment == null) {
log('Payment not found for token: $token');
print('Payment not found for token: $token');
return false;
}
@ -336,15 +340,21 @@ class PaymentManager {
List<MnemoCardsProductDto> products = const [],
}) async {
print('🔍 PaymentManager.createYookassaUrl: Starting');
print('🔍 PaymentManager.createYookassaUrl: amount=$amount, userId=$userId, products=${products.length}');
print('🔍 PaymentManager.createYookassaUrl: Calling YooMoneyHandler.createPayment');
print(
'🔍 PaymentManager.createYookassaUrl: amount=$amount, userId=$userId, products=${products.length}',
);
print(
'🔍 PaymentManager.createYookassaUrl: Calling YooMoneyHandler.createPayment',
);
final yookassaPayment = await _yooMoneyHandler.createPayment(
amount: amount,
description: description,
userId: userId,
);
print('✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}');
print(
'✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}',
);
if (yookassaPayment.confirmationUrl == null) {
print('❌ PaymentManager.createYookassaUrl: confirmationUrl is null');
@ -368,7 +378,9 @@ class PaymentManager {
await createPayment(paymentDto, userId);
print('✅ PaymentManager.createYookassaUrl: Payment created in database');
print('✅ PaymentManager.createYookassaUrl: Returning confirmationUrl and paymentId');
print(
'✅ PaymentManager.createYookassaUrl: Returning confirmationUrl and paymentId',
);
return YookassaPaymentResult(
confirmationUrl: yookassaPayment.confirmationUrl!,
paymentId: yookassaPayment.id,

View file

@ -30,12 +30,10 @@ class YooMoneyHandler {
final _uuid = const Uuid();
static const String _baseUrl = 'https://api.yookassa.ru/v3';
YooMoneyHandler({
required String shopId,
required String secretKey,
}) : _shopId = shopId,
_secretKey = secretKey,
_returnUrlBase = Platform.environment['YOOKASSA_RETURN_URL'] {
YooMoneyHandler({required String shopId, required String secretKey})
: _shopId = shopId,
_secretKey = secretKey,
_returnUrlBase = Platform.environment['YOOKASSA_RETURN_URL'] {
// Validate credentials
if (_shopId.isEmpty || _secretKey.isEmpty) {
log(
@ -49,9 +47,7 @@ class YooMoneyHandler {
_dio = Dio(
BaseOptions(
baseUrl: _baseUrl,
headers: {
'Content-Type': 'application/json',
},
headers: {'Content-Type': 'application/json'},
),
);
@ -113,17 +109,11 @@ class YooMoneyHandler {
// Create payment request according to CreatePaymentRequest schema
final requestBody = {
'amount': {
'value': formattedAmount,
'currency': 'RUB',
},
'amount': {'value': formattedAmount, 'currency': 'RUB'},
'description': description.length > 128
? description.substring(0, 128)
: description, // Max 128 chars per spec
'confirmation': {
'type': 'redirect',
'return_url': returnUrl,
},
'confirmation': {'type': 'redirect', 'return_url': returnUrl},
'capture': true, // Auto-capture payment when succeeded
'metadata': {
'userId': userId,
@ -146,11 +136,7 @@ class YooMoneyHandler {
final response = await _dio!.post<Map<String, dynamic>>(
'/payments',
data: requestBody,
options: Options(
headers: {
'Idempotence-Key': idempotenceKey,
},
),
options: Options(headers: {'Idempotence-Key': idempotenceKey}),
);
final paymentData = response.data;
@ -175,30 +161,15 @@ class YooMoneyHandler {
if (confirmationType == 'redirect') {
confirmationUrl = confirmation['confirmation_url'] as String?;
} else {
log(
'Payment created with confirmation type: $confirmationType',
name: 'YooMoneyHandler',
);
print('Payment created with confirmation type: $confirmationType');
}
}
if (confirmationUrl == null) {
log(
'Warning: No confirmation URL in payment response',
name: 'YooMoneyHandler',
error: jsonEncode(paymentData),
);
print('Warning: No confirmation URL in payment response');
}
log(
'YooKassa payment created successfully',
name: 'YooMoneyHandler',
error: {
'paymentId': paymentId,
'status': paymentStatus,
'hasConfirmationUrl': confirmationUrl != null,
},
);
print('YooKassa payment created successfully');
return YookassaPayment(
id: paymentId,
@ -206,28 +177,15 @@ class YooMoneyHandler {
confirmationUrl: confirmationUrl,
);
} on DioException catch (e, stackTrace) {
log(
'YooKassa API error when creating payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
print('YooKassa API error when creating payment $e $stackTrace');
if (e.response != null) {
log(
'YooKassa error response: ${e.response?.data}',
name: 'YooMoneyHandler',
);
print('YooKassa error response: ${e.response?.data}');
}
throw Exception(
'Failed to create YooKassa payment: ${e.message ?? 'Unknown error'}',
);
} on Exception catch (e, stackTrace) {
log(
'Unexpected error when creating YooKassa payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
print('Unexpected error when creating YooKassa payment $e $stackTrace');
rethrow;
}
}
@ -242,11 +200,7 @@ class YooMoneyHandler {
}
try {
log(
'Checking YooKassa payment status',
name: 'YooMoneyHandler',
error: {'paymentId': paymentId},
);
print('Checking YooKassa payment status $paymentId');
// Get payment info from YooKassa API
// According to spec: GET /v3/payments/{payment_id}
@ -274,15 +228,8 @@ class YooMoneyHandler {
}
}
log(
'YooKassa payment status retrieved',
name: 'YooMoneyHandler',
error: {
'paymentId': id,
'status': paymentStatus,
'paid': paid,
},
);
print('''YooKassa payment status retrieved
${{'paymentId': id, 'status': paymentStatus, 'paid': paid}}''');
return YookassaPayment(
id: id,
@ -290,28 +237,15 @@ class YooMoneyHandler {
confirmationUrl: confirmationUrl,
);
} on DioException catch (e, stackTrace) {
log(
'YooKassa API error when checking payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
print('YooKassa API error when checking payment $e $stackTrace');
if (e.response != null) {
log(
'YooKassa error response: ${e.response?.data}',
name: 'YooMoneyHandler',
);
print('YooKassa error response: ${e.response?.data}');
}
throw Exception(
'Failed to check YooKassa payment: ${e.message ?? 'Unknown error'}',
);
} on Exception catch (e, stackTrace) {
log(
'Unexpected error when checking YooKassa payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
print('Unexpected error when checking YooKassa payment $e $stackTrace');
rethrow;
}
}

View file

@ -602,10 +602,7 @@ class PacksApiV2 {
/// to allow image preview in public pack listings
@Route.get('/packs/<packId>/cover')
@OpenApiRouteHttp()
Future<Response> getPackCover(
Request request,
String packId,
) async {
Future<Response> getPackCover(Request request, String packId) async {
try {
if (packId.isEmpty) {
return _badRequest('Invalid pack ID');

View file

@ -23,11 +23,7 @@ class PurchasesApiV2 {
final PackManager _packManager;
final AppDatabase _db;
PurchasesApiV2(
this._paymentManager,
this._packManager,
this._db,
);
PurchasesApiV2(this._paymentManager, this._packManager, this._db);
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
Response.ok(
@ -70,10 +66,7 @@ class PurchasesApiV2 {
/// Create purchase for a pack
@Route.post('/purchases/packs/<packId>')
@OpenApiRouteHttp()
Future<Response> createPackPurchase(
Request request,
String packId,
) async {
Future<Response> createPackPurchase(Request request, String packId) async {
try {
print('🔍 createPackPurchase: Starting for packId=$packId');
final user = request.user;
@ -87,7 +80,9 @@ class PurchasesApiV2 {
return _unauthorized();
}
print('🔍 createPackPurchase: Getting pack information for packId=$packId');
print(
'🔍 createPackPurchase: Getting pack information for packId=$packId',
);
// Get pack information
final pack = await _packManager.getPack(packId);
if (pack == null) {
@ -96,12 +91,11 @@ class PurchasesApiV2 {
}
print('✅ createPackPurchase: Pack found: ${pack.title}');
print('🔍 createPackPurchase: Checking pack access for userId=${user.id}, packId=$packId');
// Check if already purchased
final hasAccess = await _db.userDao.hasPackAccess(
user.id!,
packId,
print(
'🔍 createPackPurchase: Checking pack access for userId=${user.id}, packId=$packId',
);
// Check if already purchased
final hasAccess = await _db.userDao.hasPackAccess(user.id!, packId);
if (hasAccess) {
print('❌ createPackPurchase: Pack already purchased');
return _badRequest('Pack is already purchased');
@ -129,10 +123,7 @@ class PurchasesApiV2 {
// Create products list
final products = [
MnemoCardsProductDto(
type: MnemoCardsProductType.pack,
id: packId,
),
MnemoCardsProductDto(type: MnemoCardsProductType.pack, id: packId),
];
print('🔍 createPackPurchase: Creating YooKassa payment URL');
@ -143,11 +134,14 @@ class PurchasesApiV2 {
userId: user.id!,
products: products,
);
print('✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}');
print(
'✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}',
);
// Build return URL for payment verification
final baseUri = request.requestedUri;
final checkUrl = '${baseUri.scheme}://${baseUri.host}${baseUri.hasPort ? ':${baseUri.port}' : ''}/api/v2/purchases/payments/${paymentResult.paymentId}/verify?productId=$packId&productType=pack';
final checkUrl =
'${baseUri.scheme}://${baseUri.host}${baseUri.hasPort ? ':${baseUri.port}' : ''}/api/v2/purchases/payments/${paymentResult.paymentId}/verify?productId=$packId&productType=pack';
return _ok({
'purchaseUrl': paymentResult.confirmationUrl,
@ -207,10 +201,7 @@ class PurchasesApiV2 {
}
// Check if already purchased
final hasAccess = await _db.userDao.hasPackAccess(
user.id!,
productId,
);
final hasAccess = await _db.userDao.hasPackAccess(user.id!, productId);
if (hasAccess) {
return _badRequest('Pack is already purchased');
}
@ -230,10 +221,7 @@ class PurchasesApiV2 {
description = 'Покупка пакета: ${pack.title}';
products = [
MnemoCardsProductDto(
type: MnemoCardsProductType.pack,
id: productId,
),
MnemoCardsProductDto(type: MnemoCardsProductType.pack, id: productId),
];
} else if (productType == MnemoCardsProductType.subscription) {
// Get subscription plan
@ -292,7 +280,8 @@ class PurchasesApiV2 {
// Build return URL for payment verification
final baseUri = request.requestedUri;
final checkUrl = '${baseUri.scheme}://${baseUri.host}${baseUri.hasPort ? ':${baseUri.port}' : ''}/api/v2/purchases/payments/${paymentResult.paymentId}/verify?productId=$productId&productType=$productTypeStr';
final checkUrl =
'${baseUri.scheme}://${baseUri.host}${baseUri.hasPort ? ':${baseUri.port}' : ''}/api/v2/purchases/payments/${paymentResult.paymentId}/verify?productId=$productId&productType=$productTypeStr';
return _ok({
'purchaseUrl': paymentResult.confirmationUrl,
@ -309,10 +298,7 @@ class PurchasesApiV2 {
/// Verify payment status
@Route.get('/purchases/payments/<paymentId>/verify')
@OpenApiRouteHttp()
Future<Response> verifyPayment(
Request request,
String paymentId,
) async {
Future<Response> verifyPayment(Request request, String paymentId) async {
try {
final user = request.user;
if (user == null) {
@ -360,10 +346,7 @@ class PurchasesApiV2 {
/// Check pack purchase status
@Route.get('/purchases/packs/<packId>/status')
@OpenApiRouteHttp()
Future<Response> getPackPurchaseStatus(
Request request,
String packId,
) async {
Future<Response> getPackPurchaseStatus(Request request, String packId) async {
try {
final user = request.user;
if (user == null) {
@ -384,13 +367,14 @@ class PurchasesApiV2 {
final isPurchased = await _db.userDao.hasPackAccess(user.id!, packId);
// Check subscription access
final activeSubscription = await _db.subscriptionDao.getActiveSubscription(
user.id!,
);
final hasSubscriptionAccess = activeSubscription != null &&
final activeSubscription = await _db.subscriptionDao
.getActiveSubscription(user.id!);
final hasSubscriptionAccess =
activeSubscription != null &&
(activeSubscription.features is List &&
(activeSubscription.features as List)
.contains(SubscriptionFeatureEnum.packs.name));
(activeSubscription.features as List).contains(
SubscriptionFeatureEnum.packs.name,
));
return _ok({
'packId': packId,
@ -399,11 +383,14 @@ class PurchasesApiV2 {
'hasSubscriptionAccess': hasSubscriptionAccess,
});
} catch (e, s) {
developer.log('Error in getPackPurchaseStatus: $e', error: e, stackTrace: s);
developer.log(
'Error in getPackPurchaseStatus: $e',
error: e,
stackTrace: s,
);
return _internalServerError(e.toString());
}
}
Router get router => _$PurchasesApiV2Router(this);
}

View file

@ -44,8 +44,12 @@ mixin SoftDeleteMixin<T extends Table, D> on DatabaseAccessor<AppDatabase> {
///
/// Возвращает null если запись не найдена или удалена.
Future<D?> getActiveById(String id) async {
print('🔍 SoftDeleteMixin.getActiveById: id=$id, table=${table.entityName}');
print('🔍 SoftDeleteMixin.getActiveById: SQL: SELECT * FROM ${table.entityName} WHERE id = ? AND is_deleted = false LIMIT 1');
print(
'🔍 SoftDeleteMixin.getActiveById: id=$id, table=${table.entityName}',
);
print(
'🔍 SoftDeleteMixin.getActiveById: SQL: SELECT * FROM ${table.entityName} WHERE id = ? AND is_deleted = false LIMIT 1',
);
final query = selectActive()
..where((t) {
final idColumn = (t as dynamic).id;

View file

@ -24,7 +24,9 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
/// Получить пак по ID
Future<CardPack?> getPackById(String id) async {
print('🔍 PackDao.getPackById: packId=$id');
print('🔍 PackDao.getPackById: SQL: SELECT * FROM card_packs WHERE id = ? LIMIT 1');
print(
'🔍 PackDao.getPackById: SQL: SELECT * FROM card_packs WHERE id = ? LIMIT 1',
);
final query = select(cardPacks)
..where((p) => p.id.equals(id))
..limit(1);

View file

@ -19,7 +19,9 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
print('🔍 PaymentDao.getPaymentById: paymentId=$id');
print('🔍 PaymentDao.getPaymentById: Calling getActiveById');
final result = await getActiveById(id);
print('✅ PaymentDao.getPaymentById: Result=${result != null ? "found" : "not found"}');
print(
'✅ PaymentDao.getPaymentById: Result=${result != null ? "found" : "not found"}',
);
return result;
}
@ -51,48 +53,68 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
/// Создать платеж
Future<String> createPayment(PaymentsCompanion payment) async {
print('🔍 PaymentDao.createPayment: Starting payment creation');
print('🔍 PaymentDao.createPayment: externalToken=${payment.externalToken.value}');
print(
'🔍 PaymentDao.createPayment: externalToken=${payment.externalToken.value}',
);
print('🔍 PaymentDao.createPayment: userId=${payment.userId.value}');
print('🔍 PaymentDao.createPayment: amount=${payment.amount.value}');
final inserted = await into(payments).insertReturning(payment);
print('✅ PaymentDao.createPayment: Payment inserted, date=${inserted.date.dateTime}');
print(
'✅ PaymentDao.createPayment: Payment inserted, date=${inserted.date.dateTime}',
);
// Try to get id from inserted object first
if (inserted.id.isNotEmpty) {
print('✅ PaymentDao.createPayment: Got id from inserted object, id=${inserted.id}');
print(
'✅ PaymentDao.createPayment: Got id from inserted object, id=${inserted.id}',
);
return inserted.id;
}
// Query back to get the id using type-safe queries
// Use externalToken if available, otherwise query by unique fields
if (inserted.externalToken != null && inserted.externalToken!.isNotEmpty) {
print('🔍 PaymentDao.createPayment: Querying by externalToken=${inserted.externalToken}');
final foundPayment = await getPaymentByExternalToken(inserted.externalToken!);
print(
'🔍 PaymentDao.createPayment: Querying by externalToken=${inserted.externalToken}',
);
final foundPayment = await getPaymentByExternalToken(
inserted.externalToken!,
);
if (foundPayment != null && foundPayment.id.isNotEmpty) {
print('✅ PaymentDao.createPayment: Found payment by externalToken, id=${foundPayment.id}');
print(
'✅ PaymentDao.createPayment: Found payment by externalToken, id=${foundPayment.id}',
);
return foundPayment.id;
}
print('⚠️ PaymentDao.createPayment: No payment found by externalToken');
}
// Fallback: query by userId, date, and amount using type-safe queries
print('🔍 PaymentDao.createPayment: Querying by userId, date, amount (fallback)');
print(
'🔍 PaymentDao.createPayment: Querying by userId, date, amount (fallback)',
);
final query = select(payments)
..where((p) =>
p.userId.equals(payment.userId.value) &
p.date.equals(inserted.date) &
p.amount.equals(inserted.amount))
..where(
(p) =>
p.userId.equals(payment.userId.value) &
p.date.equals(inserted.date) &
p.amount.equals(inserted.amount),
)
..orderBy([(p) => OrderingTerm.desc(p.createdAt)])
..limit(1);
final results = await query.get();
if (results.isNotEmpty && results.first.id.isNotEmpty) {
print('✅ PaymentDao.createPayment: Found payment by fallback query, id=${results.first.id}');
print(
'✅ PaymentDao.createPayment: Found payment by fallback query, id=${results.first.id}',
);
return results.first.id;
}
print('❌ PaymentDao.createPayment: Failed to retrieve payment ID after creation');
print(
'❌ PaymentDao.createPayment: Failed to retrieve payment ID after creation',
);
throw Exception('Failed to retrieve payment ID after creation');
}

View file

@ -26,10 +26,12 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
/// Получить пользователя с UserData
Future<UserWithData?> getUserWithDataById(String id) async {
final query = select(users).join([
leftOuterJoin(userDatas, userDatas.userId.equalsExp(users.id)),
])..where(users.id.equals(id))
..limit(1);
final query =
select(users).join([
leftOuterJoin(userDatas, userDatas.userId.equalsExp(users.id)),
])
..where(users.id.equals(id))
..limit(1);
final results = await query.get();
if (results.isEmpty) return null;
@ -146,7 +148,9 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
/// Получить UserData пользователя
Future<UserData?> getUserData(String userId) async {
print('🔍 UserDao.getUserData: userId=$userId');
print('🔍 UserDao.getUserData: SQL: SELECT * FROM user_datas WHERE user_id = ? LIMIT 1');
print(
'🔍 UserDao.getUserData: SQL: SELECT * FROM user_datas WHERE user_id = ? LIMIT 1',
);
final query = select(userDatas)
..where((ud) => ud.userId.equals(userId))
..limit(1);
@ -202,8 +206,7 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
final query = select(tokens)
..where(
(t) =>
t.externalUserId.equals(externalUserId) &
t.isDeleted.equals(false),
t.externalUserId.equals(externalUserId) & t.isDeleted.equals(false),
)
..orderBy([(t) => OrderingTerm.desc(t.created)])
..limit(1);
@ -216,9 +219,7 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
Future<Token?> getTokenByUserId(String userId) async {
final query = select(tokens)
..where((t) => t.userId.equals(userId) & t.isDeleted.equals(false))
..where(
(t) => t.expires.isBiggerThanValue(PgDateTime(DateTime.now())),
)
..where((t) => t.expires.isBiggerThanValue(PgDateTime(DateTime.now())))
..orderBy([(t) => OrderingTerm.desc(t.created)])
..limit(1);
@ -397,13 +398,17 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
/// Проверить, есть ли у пользователя доступ к паку
Future<bool> hasPackAccess(String userId, String packId) async {
print('🔍 UserDao.hasPackAccess: userId=$userId, packId=$packId');
print('🔍 UserDao.hasPackAccess: SQL: SELECT * FROM user_packs WHERE user_id = ? AND pack_id = ? LIMIT 1');
print(
'🔍 UserDao.hasPackAccess: SQL: SELECT * FROM user_packs WHERE user_id = ? AND pack_id = ? LIMIT 1',
);
final query = select(userPacks)
..where((up) => up.userId.equals(userId) & up.packId.equals(packId))
..limit(1);
final results = await query.get();
print('✅ UserDao.hasPackAccess: Result count=${results.length}, hasAccess=${results.isNotEmpty}');
print(
'✅ UserDao.hasPackAccess: Result count=${results.length}, hasAccess=${results.isNotEmpty}',
);
return results.isNotEmpty;
}

View file

@ -337,9 +337,9 @@ class AppDatabase extends _$AppDatabase {
// 2. Удаляем все старые вопросы (soft delete)
print('Soft-deleting all existing test questions...');
final now = PgDateTime(DateTime.now());
await (update(testQuestions)
..where((tq) => tq.isDeleted.equals(false)))
.write(
await (update(
testQuestions,
)..where((tq) => tq.isDeleted.equals(false))).write(
TestQuestionsCompanion(
isDeleted: const Value(true),
deletedAt: Value(now),

View file

@ -15,7 +15,7 @@ extension CardPackToDto on CardPack {
String? coverUrl;
if (cover != null && cover!.isNotEmpty) {
final coverValue = cover!.trim();
// If it's already a remote URL, use it directly
if (CardImageStorage.isRemoteUrl(coverValue)) {
coverUrl = coverValue;

View file

@ -40,9 +40,10 @@ class PackDtoConverter {
String? coverUrl;
if (model.cover != null && model.cover!.isNotEmpty) {
final coverValue = model.cover!.trim();
// If it's already a remote URL, use it directly
if (coverValue.startsWith('http://') || coverValue.startsWith('https://')) {
if (coverValue.startsWith('http://') ||
coverValue.startsWith('https://')) {
coverUrl = coverValue;
}
// If it's a UUID or any other value, use API endpoint

View file

@ -131,30 +131,40 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
slotPositions.add(i);
}
}
// Map template slot positions to answer letter positions
// Answer may contain spaces, so we need to map slots to letters (excluding spaces)
final answerLetters = answer.replaceAll(' ', '');
final templateToAnswerIndex = <int, int>{};
int answerLetterIndex = 0;
for (int i = 0; i < template.length && answerLetterIndex < answerLetters.length; i++) {
for (
int i = 0;
i < template.length && answerLetterIndex < answerLetters.length;
i++
) {
if (template[i] == '_' || template[i] == '|') {
templateToAnswerIndex[i] = answerLetterIndex;
answerLetterIndex++;
}
// Skip other characters in template (visible letters, spaces)
}
int visibleLetters = (visibleButtonsPercent * slotPositions.length).floor();
int visibleLetters = (visibleButtonsPercent * slotPositions.length)
.floor();
final shuffledPositions = List<int>.from(slotPositions)..shuffle(random);
final positionsToReveal = shuffledPositions.take(visibleLetters).toList();
// Replace slots with actual letters from answer
for (final pos in positionsToReveal) {
final answerLetterIndex = templateToAnswerIndex[pos];
if (answerLetterIndex != null && answerLetterIndex < answerLetters.length) {
template = template.replaceRange(pos, pos + 1, answerLetters[answerLetterIndex]);
if (answerLetterIndex != null &&
answerLetterIndex < answerLetters.length) {
template = template.replaceRange(
pos,
pos + 1,
answerLetters[answerLetterIndex],
);
}
}
}

View file

@ -50,3 +50,4 @@ VACUUM FULL ANALYZE payments;

View file

@ -123,3 +123,4 @@ DROP TYPE IF EXISTS grant_type CASCADE;

View file

@ -195,3 +195,4 @@ PRINT 'Migration 003 completed successfully!';

View file

@ -29,33 +29,25 @@ void main() {
late AccessService accessService;
late UserModel adminUser;
Request buildAdminRequest(
String method,
String url, {
Object? body,
}) {
Request buildAdminRequest(String method, String url, {Object? body}) {
return Request(
method,
Uri.parse(url),
body: body == null ? null : jsonEncode(body),
).change(
context: {
'user': adminUser,
'accessService': accessService,
},
);
).change(context: {'user': adminUser, 'accessService': accessService});
}
setUpAll(() async {
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database =
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ??
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username =
Platform.environment['TEST_DB_USER'] ??
Platform.environment['DB_USER'] ??
'mnemo_user';
final password = Platform.environment['TEST_DB_PASSWORD'] ??
final password =
Platform.environment['TEST_DB_PASSWORD'] ??
Platform.environment['DB_PASSWORD'] ??
'';
@ -78,7 +70,10 @@ void main() {
final discountsManager = DiscountsManager(db);
final productsPriceResolver = ProductsPriceResolver(discountsManager, db);
final adsManager = AdsManager();
final packDtoConverter = PackDtoConverter(productsPriceResolver, adsManager);
final packDtoConverter = PackDtoConverter(
productsPriceResolver,
adsManager,
);
final packManager = PackManager(db, packDtoConverter);
final resourceLoader = ResourceLoader(packManager);
@ -107,23 +102,25 @@ void main() {
Future<void> cleanup() async {
// Delete relations first.
if (testId != null) {
await (db.delete(db.testPackRelations)
..where((r) => r.testId.equals(testId!)))
.go();
await (db.delete(
db.testPackRelations,
)..where((r) => r.testId.equals(testId!))).go();
await (db.delete(db.testQuestions)
..where((q) => q.testId.equals(testId!)))
.go();
await (db.delete(
db.testQuestions,
)..where((q) => q.testId.equals(testId!))).go();
await (db.delete(db.tests)..where((t) => t.id.equals(testId!))).go();
}
if (packId != null) {
await (db.delete(db.cardPackCards)
..where((c) => c.packId.equals(packId!)))
.go();
await (db.delete(
db.cardPackCards,
)..where((c) => c.packId.equals(packId!))).go();
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!))).go();
await (db.delete(
db.cardPacks,
)..where((p) => p.id.equals(packId!))).go();
}
for (final cardId in createdCardIds) {
@ -131,7 +128,9 @@ void main() {
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId))).go();
// Remove possible image files in data/cards
final cardsDir = Directory('${PackManagerUtils.assetsDirectory.path}/cards');
final cardsDir = Directory(
'${PackManagerUtils.assetsDirectory.path}/cards',
);
final candidates = [
File('${cardsDir.path}/$cardId.png'),
File('${cardsDir.path}/$cardId.webp'),
@ -159,144 +158,162 @@ void main() {
await cleanup();
});
test('getTest returns image URLs (no base64) and cards are linked to pack',
() async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'pack for admin tests images',
subtitle: 'subtitle',
size: 0,
),
);
test(
'getTest returns image URLs (no base64) and cards are linked to pack',
() async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'pack for admin tests images',
subtitle: 'subtitle',
size: 0,
),
);
// Create test with base64 cover + base64 question/button images
final createRequest = buildAdminRequest(
'POST',
'http://localhost/api/v2/admin/tests',
body: {
'name': 'test with images',
'cover': oneByOnePngBase64,
'questions': [
{
'questionType': 'simple',
'word': 'word',
'answer': 'btn1',
'image': oneByOnePngBase64,
'buttons': [
{
'id': 'btn1',
'text': 'ok',
'image': oneByOnePngBase64,
},
],
},
],
},
);
// Create test with base64 cover + base64 question/button images
final createRequest = buildAdminRequest(
'POST',
'http://localhost/api/v2/admin/tests',
body: {
'name': 'test with images',
'cover': oneByOnePngBase64,
'questions': [
{
'questionType': 'simple',
'word': 'word',
'answer': 'btn1',
'image': oneByOnePngBase64,
'buttons': [
{'id': 'btn1', 'text': 'ok', 'image': oneByOnePngBase64},
],
},
],
},
);
final createResp = await adminTestsApi.upsertTest(createRequest);
expect(createResp.statusCode, anyOf(equals(201), equals(200)));
final createBody = jsonDecode(await createResp.readAsString())
as Map<String, dynamic>;
testId = (createBody['test'] as Map<String, dynamic>)['id'] as String;
expect(testId, isNotEmpty);
final createResp = await adminTestsApi.upsertTest(createRequest);
expect(createResp.statusCode, anyOf(equals(201), equals(200)));
final createBody =
jsonDecode(await createResp.readAsString()) as Map<String, dynamic>;
testId = (createBody['test'] as Map<String, dynamic>)['id'] as String;
expect(testId, isNotEmpty);
// Link test to pack via admin packs API (this must also link referenced cards)
final linkRequest = buildAdminRequest(
'POST',
'http://localhost/api/v2/admin/packs',
body: {
'id': packId,
'title': 'pack for admin tests images',
'addTestIds': [testId],
},
);
// Link test to pack via admin packs API (this must also link referenced cards)
final linkRequest = buildAdminRequest(
'POST',
'http://localhost/api/v2/admin/packs',
body: {
'id': packId,
'title': 'pack for admin tests images',
'addTestIds': [testId],
},
);
final linkResp = await adminPacksApi.upsertPack(linkRequest);
expect(linkResp.statusCode, equals(200));
final linkResp = await adminPacksApi.upsertPack(linkRequest);
expect(linkResp.statusCode, equals(200));
// Fetch test and verify URLs
final getReq = buildAdminRequest(
'GET',
'http://localhost/api/v2/admin/tests/$testId',
);
final getResp = await adminTestsApi.getTest(getReq, testId!);
expect(getResp.statusCode, equals(200));
// Fetch test and verify URLs
final getReq = buildAdminRequest(
'GET',
'http://localhost/api/v2/admin/tests/$testId',
);
final getResp = await adminTestsApi.getTest(getReq, testId!);
expect(getResp.statusCode, equals(200));
final body = jsonDecode(await getResp.readAsString())
as Map<String, dynamic>;
final body =
jsonDecode(await getResp.readAsString()) as Map<String, dynamic>;
final cover = body['cover'] as String?;
expect(cover, isNotNull);
expect(cover, isNot(contains(oneByOnePngBase64)));
expect(cover, startsWith('/api/v2/packs/$packId/cards/'));
expect(cover, endsWith('/image'));
final cover = body['cover'] as String?;
expect(cover, isNotNull);
expect(cover, isNot(contains(oneByOnePngBase64)));
expect(cover, startsWith('/api/v2/packs/$packId/cards/'));
expect(cover, endsWith('/image'));
final questions = body['questions'] as List<dynamic>;
expect(questions, hasLength(1));
final q0 = questions.first as Map<String, dynamic>;
final questions = body['questions'] as List<dynamic>;
expect(questions, hasLength(1));
final q0 = questions.first as Map<String, dynamic>;
final qImage = q0['image'] as String?;
expect(qImage, isNotNull);
expect(qImage, isNot(contains(oneByOnePngBase64)));
expect(qImage, startsWith('/api/v2/packs/$packId/cards/'));
expect(qImage, endsWith('/image'));
final qImage = q0['image'] as String?;
expect(qImage, isNotNull);
expect(qImage, isNot(contains(oneByOnePngBase64)));
expect(qImage, startsWith('/api/v2/packs/$packId/cards/'));
expect(qImage, endsWith('/image'));
final buttons = q0['buttons'] as List<dynamic>;
expect(buttons, hasLength(1));
final b0 = buttons.first as Map<String, dynamic>;
final bImage = b0['image'] as String?;
expect(bImage, isNotNull);
expect(bImage, isNot(contains(oneByOnePngBase64)));
expect(bImage, startsWith('/api/v2/packs/$packId/cards/'));
expect(bImage, endsWith('/image'));
final buttons = q0['buttons'] as List<dynamic>;
expect(buttons, hasLength(1));
final b0 = buttons.first as Map<String, dynamic>;
final bImage = b0['image'] as String?;
expect(bImage, isNotNull);
expect(bImage, isNot(contains(oneByOnePngBase64)));
expect(bImage, startsWith('/api/v2/packs/$packId/cards/'));
expect(bImage, endsWith('/image'));
// Extract card ids from URLs and ensure they are linked to the pack.
final extractCardId = (String url) {
final match = RegExp(r'/cards/([^/]+)/image$').firstMatch(url);
return match?.group(1);
};
// Extract card ids from URLs and ensure they are linked to the pack.
final extractCardId = (String url) {
final match = RegExp(r'/cards/([^/]+)/image$').firstMatch(url);
return match?.group(1);
};
final coverCardId = extractCardId(cover!);
final qCardId = extractCardId(qImage!);
final bCardId = extractCardId(bImage!);
final coverCardId = extractCardId(cover!);
final qCardId = extractCardId(qImage!);
final bCardId = extractCardId(bImage!);
expect(coverCardId, isNotNull);
expect(qCardId, isNotNull);
expect(bCardId, isNotNull);
expect(coverCardId, isNotNull);
expect(qCardId, isNotNull);
expect(bCardId, isNotNull);
createdCardIds.addAll([coverCardId!, qCardId!, bCardId!]);
createdCardIds.addAll([coverCardId!, qCardId!, bCardId!]);
final linked = await (db.select(db.cardPackCards)
..where((c) => c.packId.equals(packId!) &
c.cardId.isIn([coverCardId, qCardId, bCardId])))
.get();
expect(linked.map((e) => e.cardId).toSet(),
containsAll([coverCardId, qCardId, bCardId]));
final linked =
await (db.select(db.cardPackCards)..where(
(c) =>
c.packId.equals(packId!) &
c.cardId.isIn([coverCardId, qCardId, bCardId]),
))
.get();
expect(
linked.map((e) => e.cardId).toSet(),
containsAll([coverCardId, qCardId, bCardId]),
);
// Also ensure DB stores cardIds, not API URLs/base64.
final dbQuestions = await db.testDao.getTestQuestions(testId!);
expect(dbQuestions, hasLength(1));
final qDb = dbQuestions.single;
final options = jsonDecode(qDb.options) as List<dynamic>;
final uiData = jsonDecode(qDb.uiData) as Map<String, dynamic>;
// Also ensure DB stores cardIds, not API URLs/base64.
final dbQuestions = await db.testDao.getTestQuestions(testId!);
expect(dbQuestions, hasLength(1));
final qDb = dbQuestions.single;
final options = jsonDecode(qDb.options) as List<dynamic>;
final uiData = jsonDecode(qDb.uiData) as Map<String, dynamic>;
final storedUiImage = uiData['image']?.toString();
expect(storedUiImage, isNotNull);
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
.hasMatch(storedUiImage!), isTrue);
final storedUiImage = uiData['image']?.toString();
expect(storedUiImage, isNotNull);
expect(
RegExp(
r'^[0-9a-f\-]{36}$',
caseSensitive: false,
).hasMatch(storedUiImage!),
isTrue,
);
final storedBtnImage =
(options.first as Map<String, dynamic>)['image']?.toString();
expect(storedBtnImage, isNotNull);
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
.hasMatch(storedBtnImage!), isTrue);
final storedBtnImage = (options.first as Map<String, dynamic>)['image']
?.toString();
expect(storedBtnImage, isNotNull);
expect(
RegExp(
r'^[0-9a-f\-]{36}$',
caseSensitive: false,
).hasMatch(storedBtnImage!),
isTrue,
);
final storedCover = (await db.testDao.getTestById(testId!))!.cover;
expect(storedCover, isNotNull);
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
.hasMatch(storedCover!), isTrue);
});
final storedCover = (await db.testDao.getTestById(testId!))!.cover;
expect(storedCover, isNotNull);
expect(
RegExp(
r'^[0-9a-f\-]{36}$',
caseSensitive: false,
).hasMatch(storedCover!),
isTrue,
);
},
);
});
}

View file

@ -78,7 +78,11 @@ void main() {
});
authApiV2 = AuthApiV2(
userManager, mockGoogleApi, jwtService, telegramAuthCodeService);
userManager,
mockGoogleApi,
jwtService,
telegramAuthCodeService,
);
});
tearDown(() async {
@ -101,8 +105,9 @@ void main() {
const email = 'user@example.com';
const name = 'Test User';
when(mockGoogleApi.getGoogleUserId(googleIdToken))
.thenAnswer((_) async => googleUserId);
when(
mockGoogleApi.getGoogleUserId(googleIdToken),
).thenAnswer((_) async => googleUserId);
final request = Request(
'POST',
@ -151,8 +156,9 @@ void main() {
test('should return 401 when Google token is invalid', () async {
const googleIdToken = 'invalid_google_token';
when(mockGoogleApi.getGoogleUserId(googleIdToken))
.thenAnswer((_) async => null);
when(
mockGoogleApi.getGoogleUserId(googleIdToken),
).thenAnswer((_) async => null);
final request = Request(
'POST',
@ -267,9 +273,7 @@ void main() {
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/auth/me'),
headers: {
'Authorization': 'Bearer ${tokens.accessToken}',
},
headers: {'Authorization': 'Bearer ${tokens.accessToken}'},
).change(context: {'user': testUser});
final response = await authApiV2.getCurrentUser(request);
@ -340,9 +344,7 @@ void main() {
'POST',
Uri.parse('http://localhost/api/v2/auth/logout'),
body: '',
headers: {
'Authorization': 'Bearer ${tokens.accessToken}',
},
headers: {'Authorization': 'Bearer ${tokens.accessToken}'},
).change(context: {'user': testUser});
final response = await authApiV2.logout(request);
@ -369,117 +371,132 @@ void main() {
});
group('AuthApiV2 - Telegram Web Code Flow', () {
test('should create, claim, and authenticate using web-generated code',
() async {
// Step 1: create code from web
final createRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/telegram/web-code'),
body: '',
);
test(
'should create, claim, and authenticate using web-generated code',
() async {
// Step 1: create code from web
final createRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/telegram/web-code'),
body: '',
);
final createResponse =
await authApiV2.createWebTelegramCode(createRequest);
final createBody = jsonDecode(await createResponse.readAsString())
as Map<String, dynamic>;
final createResponse = await authApiV2.createWebTelegramCode(
createRequest,
);
final createBody =
jsonDecode(await createResponse.readAsString())
as Map<String, dynamic>;
expect(createResponse.statusCode, equals(200));
final code = createBody['code'] as String?;
expect(code, isNotNull);
expect(createBody['status'], equals('pending'));
expect(createBody['remainingSeconds'], greaterThan(0));
expect(createBody['remainingSeconds'], lessThanOrEqualTo(300));
expect(createResponse.statusCode, equals(200));
final code = createBody['code'] as String?;
expect(code, isNotNull);
expect(createBody['status'], equals('pending'));
expect(createBody['remainingSeconds'], greaterThan(0));
expect(createBody['remainingSeconds'], lessThanOrEqualTo(300));
// Step 2: status should be pending before claim
final statusRequest = Request(
'GET',
Uri.parse('http://localhost/api/v2/auth/telegram/code-status/$code'),
);
// Step 2: status should be pending before claim
final statusRequest = Request(
'GET',
Uri.parse('http://localhost/api/v2/auth/telegram/code-status/$code'),
);
final statusResponse =
await authApiV2.getTelegramCodeStatus(statusRequest, code!);
final statusBody = jsonDecode(await statusResponse.readAsString())
as Map<String, dynamic>;
final statusResponse = await authApiV2.getTelegramCodeStatus(
statusRequest,
code!,
);
final statusBody =
jsonDecode(await statusResponse.readAsString())
as Map<String, dynamic>;
expect(statusResponse.statusCode, equals(200));
expect(statusBody['status'], equals('pending'));
expect(statusBody['isClaimed'], isFalse);
expect(statusBody['isUsed'], isFalse);
expect(statusResponse.statusCode, equals(200));
expect(statusBody['status'], equals('pending'));
expect(statusBody['isClaimed'], isFalse);
expect(statusBody['isUsed'], isFalse);
// Step 3: authentication before claim should fail
final preAuthRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/oauth/telegram'),
body: jsonEncode({'code': code}),
headers: {'Content-Type': 'application/json'},
);
// Step 3: authentication before claim should fail
final preAuthRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/oauth/telegram'),
body: jsonEncode({'code': code}),
headers: {'Content-Type': 'application/json'},
);
final preAuthResponse =
await authApiV2.authenticateTelegram(preAuthRequest);
expect(preAuthResponse.statusCode, equals(401));
final preAuthResponse = await authApiV2.authenticateTelegram(
preAuthRequest,
);
expect(preAuthResponse.statusCode, equals(401));
// Step 4: claim code via bot
final claimRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/telegram/claim-code'),
body: jsonEncode({
'code': code,
'telegramUserId': 'telegram_user_1',
'telegramUsername': 'webuser',
'firstName': 'Web',
'lastName': 'User',
}),
headers: {'Content-Type': 'application/json'},
);
// Step 4: claim code via bot
final claimRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/telegram/claim-code'),
body: jsonEncode({
'code': code,
'telegramUserId': 'telegram_user_1',
'telegramUsername': 'webuser',
'firstName': 'Web',
'lastName': 'User',
}),
headers: {'Content-Type': 'application/json'},
);
final claimResponse = await authApiV2.claimTelegramCode(claimRequest);
final claimBody = jsonDecode(await claimResponse.readAsString())
as Map<String, dynamic>;
final claimResponse = await authApiV2.claimTelegramCode(claimRequest);
final claimBody =
jsonDecode(await claimResponse.readAsString())
as Map<String, dynamic>;
expect(claimResponse.statusCode, equals(200));
expect(claimBody['status'], equals('claimed'));
expect(claimBody['remainingSeconds'], greaterThan(0));
expect(claimResponse.statusCode, equals(200));
expect(claimBody['status'], equals('claimed'));
expect(claimBody['remainingSeconds'], greaterThan(0));
// Step 5: status should be claimed
final claimedStatusResponse =
await authApiV2.getTelegramCodeStatus(statusRequest, code);
final claimedStatusBody =
jsonDecode(await claimedStatusResponse.readAsString())
as Map<String, dynamic>;
// Step 5: status should be claimed
final claimedStatusResponse = await authApiV2.getTelegramCodeStatus(
statusRequest,
code,
);
final claimedStatusBody =
jsonDecode(await claimedStatusResponse.readAsString())
as Map<String, dynamic>;
expect(claimedStatusResponse.statusCode, equals(200));
expect(claimedStatusBody['status'], equals('claimed'));
expect(claimedStatusBody['isClaimed'], isTrue);
expect(claimedStatusBody['isUsed'], isFalse);
expect(claimedStatusResponse.statusCode, equals(200));
expect(claimedStatusBody['status'], equals('claimed'));
expect(claimedStatusBody['isClaimed'], isTrue);
expect(claimedStatusBody['isUsed'], isFalse);
// Step 6: authentication after claim should succeed
final authRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/oauth/telegram'),
body: jsonEncode({'code': code}),
headers: {'Content-Type': 'application/json'},
);
// Step 6: authentication after claim should succeed
final authRequest = Request(
'POST',
Uri.parse('http://localhost/api/v2/auth/oauth/telegram'),
body: jsonEncode({'code': code}),
headers: {'Content-Type': 'application/json'},
);
final authResponse = await authApiV2.authenticateTelegram(authRequest);
final authBody =
jsonDecode(await authResponse.readAsString()) as Map<String, dynamic>;
final authResponse = await authApiV2.authenticateTelegram(authRequest);
final authBody =
jsonDecode(await authResponse.readAsString())
as Map<String, dynamic>;
expect(authResponse.statusCode, equals(200));
expect(authBody['user'], isNotNull);
expect(authBody['accessToken'], isNotEmpty);
expect(authBody['refreshToken'], isNotEmpty);
expect(authResponse.statusCode, equals(200));
expect(authBody['user'], isNotNull);
expect(authBody['accessToken'], isNotEmpty);
expect(authBody['refreshToken'], isNotEmpty);
// Step 7: status reflects used code
final usedStatusResponse =
await authApiV2.getTelegramCodeStatus(statusRequest, code);
final usedStatusBody = jsonDecode(await usedStatusResponse.readAsString())
as Map<String, dynamic>;
// Step 7: status reflects used code
final usedStatusResponse = await authApiV2.getTelegramCodeStatus(
statusRequest,
code,
);
final usedStatusBody =
jsonDecode(await usedStatusResponse.readAsString())
as Map<String, dynamic>;
expect(usedStatusResponse.statusCode, equals(200));
expect(usedStatusBody['status'], equals('used'));
expect(usedStatusBody['isClaimed'], isTrue);
expect(usedStatusBody['isUsed'], isTrue);
});
expect(usedStatusResponse.statusCode, equals(200));
expect(usedStatusBody['status'], equals('used'));
expect(usedStatusBody['isClaimed'], isTrue);
expect(usedStatusBody['isUsed'], isTrue);
},
);
test('should return error when claiming unknown code', () async {
final claimRequest = Request(

View file

@ -74,10 +74,10 @@ void main() {
handler = const Pipeline()
.addMiddleware(authorizeV2(userManager, jwtService))
.addHandler((request) {
final user = request.context['user'] as UserModel?;
final body = user == null ? 'no-user' : 'user-${user.id}';
return Response.ok(body);
});
final user = request.context['user'] as UserModel?;
final body = user == null ? 'no-user' : 'user-${user.id}';
return Response.ok(body);
});
});
tearDownAll(() async {
@ -93,8 +93,11 @@ void main() {
if (token != null) {
headers['authorization'] = 'Bearer $token';
}
final request =
Request(method, Uri.parse('http://localhost$path'), headers: headers);
final request = Request(
method,
Uri.parse('http://localhost$path'),
headers: headers,
);
return Future.sync(() => handler(request));
}
@ -106,8 +109,7 @@ void main() {
});
test('attaches user context when token present on GET /packs/<id>', () async {
final response =
await _makeRequest('/packs/10', token: accessToken);
final response = await _makeRequest('/packs/10', token: accessToken);
expect(response.statusCode, equals(200));
expect(await response.readAsString(), equals('user-${testUser.id}'));
@ -117,13 +119,11 @@ void main() {
final response = await _makeRequest('/packs/10/tests');
expect(response.statusCode, equals(401));
expect(await response.readAsString(),
contains('"error":"Unauthorized"'));
expect(await response.readAsString(), contains('"error":"Unauthorized"'));
});
test('allows GET /packs/<id>/tests when token provided', () async {
final response =
await _makeRequest('/packs/10/tests', token: accessToken);
final response = await _makeRequest('/packs/10/tests', token: accessToken);
expect(response.statusCode, equals(200));
expect(await response.readAsString(), equals('user-${testUser.id}'));
@ -133,8 +133,6 @@ void main() {
final response = await _makeRequest('/packs/10', token: 'invalid');
expect(response.statusCode, equals(401));
expect(await response.readAsString(),
contains('"error":"Unauthorized"'));
expect(await response.readAsString(), contains('"error":"Unauthorized"'));
});
}

View file

@ -26,7 +26,7 @@ void main() {
expect(response.statusCode, equals(200));
expect(responseBody, isA<List>());
expect(responseBody.length, greaterThanOrEqualTo(1));
// Verify game structure
final game = responseBody[0] as Map<String, dynamic>;
expect(game['id'], isA<String>());
@ -45,12 +45,12 @@ void main() {
final responseBody = jsonDecode(await response.readAsString()) as List;
expect(response.statusCode, equals(200));
final funnyLettersGame = responseBody.firstWhere(
(game) => (game as Map<String, dynamic>)['id'] == 'funny_letters',
orElse: () => null,
);
expect(funnyLettersGame, isNotNull);
final game = funnyLettersGame as Map<String, dynamic>;
expect(game['title'], equals('Funny letters'));
@ -64,9 +64,12 @@ void main() {
Uri.parse('http://localhost/api/v2/games/nonexistent_game/assets'),
);
final response = await gamesApiV2.getGameAssets(request, 'nonexistent_game');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await gamesApiV2.getGameAssets(
request,
'nonexistent_game',
);
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -82,13 +85,13 @@ void main() {
);
final response = await gamesApiV2.getGameAssets(request, 'funny_letters');
// The response will be either 200 (if assets exist) or 404
expect(response.statusCode, isIn([200, 404]));
if (response.statusCode == 404) {
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(responseBody['error'], equals('Not Found'));
} else {
// If file exists, verify it returns binary data
@ -104,18 +107,12 @@ void main() {
);
final response = await gamesApiV2.getGameAssets(request, 'funny_letters');
// Only check headers if the file exists (status 200)
if (response.statusCode == 200) {
expect(response.headers['content-type'], equals('application/zip'));
expect(
response.headers['content-disposition'],
contains('attachment'),
);
expect(
response.headers['cache-control'],
contains('max-age=86400'),
);
expect(response.headers['content-disposition'], contains('attachment'));
expect(response.headers['cache-control'], contains('max-age=86400'));
}
});
@ -126,17 +123,16 @@ void main() {
);
final response = await gamesApiV2.getGameAssets(request, 'funny_letters');
// May be 404 (assets not found) or 200 (success)
expect(response.statusCode, isIn([200, 404]));
if (response.statusCode == 404) {
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
// Should say "assets not found" not "game not found"
expect(responseBody['message'], isNot(contains('Game not found')));
}
});
});
}

View file

@ -14,10 +14,10 @@ void main() {
setUpAll(() async {
// Initialize Isar for testing with in-memory database
await Isar.initializeIsarCore(download: true);
// Create temporary directory for test database
final testDir = Directory.systemTemp.createTempSync('isar_test_');
// Include all schemas (use same as IsarConnector to avoid dependency issues)
testIsar = await Isar.open(
[
@ -43,14 +43,14 @@ void main() {
name: 'test_db',
inspector: false,
);
// Set the global isar for JwtService to use
backend_main.isar = testIsar;
});
setUp(() async {
jwtService = JwtService();
// Create a test user
await testIsar.writeTxn(() async {
final user = UserModel.empty.copyWith(
@ -81,7 +81,7 @@ void main() {
expect(tokens.accessToken, isNotEmpty);
expect(tokens.refreshToken, isNotEmpty);
expect(tokens.expiresIn, equals(3600)); // 1 hour
// Tokens should have JWT format (3 parts separated by dots)
expect(tokens.accessToken.split('.').length, equals(3));
expect(tokens.refreshToken.split('.').length, equals(3));
@ -209,7 +209,7 @@ void main() {
test('should verify non-expired tokens', () async {
// Create fresh tokens
final tokens = await jwtService.generateTokens(testUser);
// The token should be valid immediately after creation
final userId = jwtService.verifyAccessToken(tokens.accessToken);
expect(userId, equals(testUser.id.toString()));
@ -234,7 +234,9 @@ void main() {
.findFirst();
if (token != null) {
await testIsar.refreshTokenModels.put(
token.copyWith(expiresAt: DateTime.now().subtract(Duration(hours: 1))),
token.copyWith(
expiresAt: DateTime.now().subtract(Duration(hours: 1)),
),
);
}
});
@ -274,4 +276,3 @@ void main() {
});
});
}

View file

@ -40,9 +40,7 @@ void main() {
AdminAccessPolicy(),
);
final context = <String, Object?>{
'accessService': accessService,
};
final context = <String, Object?>{'accessService': accessService};
if (user != null) {
context['user'] = user;
@ -87,7 +85,7 @@ void main() {
// Without proper multipart body, it will fail validation, but we can test access
final response = await mediaApiV2.uploadCardImage(request);
// Should not be 403 (access denied), but might be 400 (bad request) due to missing file
expect(response.statusCode, isNot(403));
});
@ -109,11 +107,13 @@ void main() {
final response = await mediaApiV2.uploadCardImage(request);
expect(response.statusCode, equals(403));
verifyNever(mockMinioService.uploadFile(
bucket: anyNamed('bucket'),
bytes: anyNamed('bytes'),
contentType: anyNamed('contentType'),
));
verifyNever(
mockMinioService.uploadFile(
bucket: anyNamed('bucket'),
bytes: anyNamed('bytes'),
contentType: anyNamed('contentType'),
),
);
});
test('should return 400 when file size exceeds limit', () async {
@ -142,16 +142,20 @@ void main() {
const testObjectId = 'test-uuid-456';
const testPresignedUrl = 'https://minio.example.com/presigned-url-2';
when(mockMinioService.uploadFile(
bucket: MinioConfig.cardImagesBucket,
bytes: anyNamed('bytes'),
contentType: 'image/jpeg',
)).thenAnswer((_) async => testObjectId);
when(
mockMinioService.uploadFile(
bucket: MinioConfig.cardImagesBucket,
bytes: anyNamed('bytes'),
contentType: 'image/jpeg',
),
).thenAnswer((_) async => testObjectId);
when(mockMinioService.getPresignedUrl(
bucket: MinioConfig.cardImagesBucket,
objectId: testObjectId,
)).thenAnswer((_) async => testPresignedUrl);
when(
mockMinioService.getPresignedUrl(
bucket: MinioConfig.cardImagesBucket,
objectId: testObjectId,
),
).thenAnswer((_) async => testPresignedUrl);
// Test images now use the same bucket as card images
expect(testObjectId, isNotEmpty);
@ -164,16 +168,20 @@ void main() {
const testObjectId = 'test-uuid-789';
const testPresignedUrl = 'https://minio.example.com/presigned-url-3';
when(mockMinioService.uploadFile(
bucket: MinioConfig.voiceAudioBucket,
bytes: anyNamed('bytes'),
contentType: 'audio/mpeg',
)).thenAnswer((_) async => testObjectId);
when(
mockMinioService.uploadFile(
bucket: MinioConfig.voiceAudioBucket,
bytes: anyNamed('bytes'),
contentType: 'audio/mpeg',
),
).thenAnswer((_) async => testObjectId);
when(mockMinioService.getPresignedUrl(
bucket: MinioConfig.voiceAudioBucket,
objectId: testObjectId,
)).thenAnswer((_) async => testPresignedUrl);
when(
mockMinioService.getPresignedUrl(
bucket: MinioConfig.voiceAudioBucket,
objectId: testObjectId,
),
).thenAnswer((_) async => testPresignedUrl);
// Similar to uploadCardImage test
expect(testObjectId, isNotEmpty);
@ -205,11 +213,13 @@ void main() {
const testPresignedUrl = 'https://minio.example.com/presigned-url-4';
const testBucket = MinioConfig.cardImagesBucket;
when(mockMinioService.getPresignedUrl(
bucket: testBucket,
objectId: testObjectId,
expirySeconds: anyNamed('expirySeconds'),
)).thenAnswer((_) async => testPresignedUrl);
when(
mockMinioService.getPresignedUrl(
bucket: testBucket,
objectId: testObjectId,
expirySeconds: anyNamed('expirySeconds'),
),
).thenAnswer((_) async => testPresignedUrl);
final request = buildRequest(
'GET',
@ -221,29 +231,33 @@ void main() {
testBucket,
testObjectId,
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['url'], equals(testPresignedUrl));
expect(responseBody['expiresAt'], isNotNull);
verify(mockMinioService.getPresignedUrl(
bucket: testBucket,
objectId: testObjectId,
expirySeconds: anyNamed('expirySeconds'),
)).called(1);
verify(
mockMinioService.getPresignedUrl(
bucket: testBucket,
objectId: testObjectId,
expirySeconds: anyNamed('expirySeconds'),
),
).called(1);
});
test('should return 404 when object does not exist', () async {
const testObjectId = 'non-existent-id';
const testBucket = MinioConfig.cardImagesBucket;
when(mockMinioService.getPresignedUrl(
bucket: testBucket,
objectId: testObjectId,
expirySeconds: anyNamed('expirySeconds'),
)).thenAnswer((_) async => null);
when(
mockMinioService.getPresignedUrl(
bucket: testBucket,
objectId: testObjectId,
expirySeconds: anyNamed('expirySeconds'),
),
).thenAnswer((_) async => null);
final request = buildRequest(
'GET',
@ -275,11 +289,13 @@ void main() {
);
expect(response.statusCode, equals(400));
verifyNever(mockMinioService.getPresignedUrl(
bucket: anyNamed('bucket'),
objectId: anyNamed('objectId'),
expirySeconds: anyNamed('expirySeconds'),
));
verifyNever(
mockMinioService.getPresignedUrl(
bucket: anyNamed('bucket'),
objectId: anyNamed('objectId'),
expirySeconds: anyNamed('expirySeconds'),
),
);
});
});
@ -288,10 +304,9 @@ void main() {
const testObjectId = 'test-uuid-delete';
const testBucket = MinioConfig.cardImagesBucket;
when(mockMinioService.deleteFile(
bucket: testBucket,
objectId: testObjectId,
)).thenAnswer((_) async => Future.value());
when(
mockMinioService.deleteFile(bucket: testBucket, objectId: testObjectId),
).thenAnswer((_) async => Future.value());
final request = buildRequest(
'DELETE',
@ -304,16 +319,15 @@ void main() {
testBucket,
testObjectId,
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['success'], isTrue);
verify(mockMinioService.deleteFile(
bucket: testBucket,
objectId: testObjectId,
)).called(1);
verify(
mockMinioService.deleteFile(bucket: testBucket, objectId: testObjectId),
).called(1);
});
test('should return 403 when user is not admin', () async {
@ -340,10 +354,12 @@ void main() {
);
expect(response.statusCode, equals(403));
verifyNever(mockMinioService.deleteFile(
bucket: anyNamed('bucket'),
objectId: anyNamed('objectId'),
));
verifyNever(
mockMinioService.deleteFile(
bucket: anyNamed('bucket'),
objectId: anyNamed('objectId'),
),
);
});
test('should return 400 for invalid bucket name', () async {
@ -363,10 +379,12 @@ void main() {
);
expect(response.statusCode, equals(400));
verifyNever(mockMinioService.deleteFile(
bucket: anyNamed('bucket'),
objectId: anyNamed('objectId'),
));
verifyNever(
mockMinioService.deleteFile(
bucket: anyNamed('bucket'),
objectId: anyNamed('objectId'),
),
);
});
});
}

View file

@ -26,11 +26,7 @@ void main() {
late PacksApiV2 packsApiV2;
late UserModel testUser;
Request buildRequest(
String method,
String url, {
UserModel? user,
}) {
Request buildRequest(String method, String url, {UserModel? user}) {
final resourceLoader = ResourceLoader(packManager);
final accessService = AccessService(
PackAccessPolicy(resourceLoader),
@ -53,7 +49,7 @@ void main() {
// Initialize Isar for testing
await Isar.initializeIsarCore(download: true);
final testDir = Directory.systemTemp.createTempSync('isar_test_');
testIsar = await Isar.open(
[
CardPackModelSchema,
@ -78,7 +74,7 @@ void main() {
name: 'test_db',
inspector: false,
);
backend_main.isar = testIsar;
});
@ -87,11 +83,8 @@ void main() {
final discountsManager = const DiscountsManager();
final productsPriceResolver = ProductsPriceResolver(discountsManager);
final adsManager = AdsManager();
packDtoConverter = PackDtoConverter(
productsPriceResolver,
adsManager,
);
packDtoConverter = PackDtoConverter(productsPriceResolver, adsManager);
packManager = PackManager(packDtoConverter);
testManager = TestManager(packDtoConverter);
packsApiV2 = PacksApiV2(packManager, testManager);
@ -139,19 +132,16 @@ void main() {
);
await testIsar.gameCardModels.putAll([card1, card2]);
// Link cards to pack
final savedPack = (await testIsar.cardPackModels.get(10))!;
await savedPack.cards.load();
savedPack.cards.addAll([card1, card2]);
await savedPack.cards.save();
// Update cardsOrder
await testIsar.cardPackModels.put(
savedPack.copyWith(
cardsOrder: [card1.id!, card2.id!],
size: 2,
),
savedPack.copyWith(cardsOrder: [card1.id!, card2.id!], size: 2),
);
final privatePack = CardPackModel(
@ -183,10 +173,7 @@ void main() {
await savedPrivatePack.cards.save();
await testIsar.cardPackModels.put(
savedPrivatePack.copyWith(
cardsOrder: [privateCard.id!],
size: 1,
),
savedPrivatePack.copyWith(cardsOrder: [privateCard.id!], size: 1),
);
});
});
@ -213,8 +200,8 @@ void main() {
);
final response = await packsApiV2.getPacks(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['items'], isA<List>());
@ -225,14 +212,11 @@ void main() {
});
test('should return packs with default pagination', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs',
);
final request = buildRequest('GET', 'http://localhost/api/v2/packs');
final response = await packsApiV2.getPacks(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['page'], equals(1));
@ -246,14 +230,15 @@ void main() {
);
final response = await packsApiV2.getPacks(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
final items = responseBody['items'] as List;
// Should find testPack with "Test" in title
final found = items.any((pack) =>
(pack['title'] as String).contains('Test'));
final found = items.any(
(pack) => (pack['title'] as String).contains('Test'),
);
expect(found, isTrue);
});
@ -264,8 +249,8 @@ void main() {
);
final response = await packsApiV2.getPacks(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -279,64 +264,67 @@ void main() {
);
final response = await packsApiV2.getPacks(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
expect(responseBody['message'], contains('Limit must be between 1 and 100'));
expect(
responseBody['message'],
contains('Limit must be between 1 and 100'),
);
});
});
group('PacksApiV2 - Get Pack', () {
test('should return pack details for public pack (id=10)', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10',
);
final request = buildRequest('GET', 'http://localhost/api/v2/packs/10');
final response = await packsApiV2.getPack(request, '10');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('10'));
expect(responseBody['title'], equals('Test Pack'));
});
test('should return pack with purchase status for authenticated user', () async {
// First, add pack to user
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(10);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
test(
'should return pack with purchase status for authenticated user',
() async {
// First, add pack to user
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(10);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
}
}
}
});
});
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10',
user: updatedUser,
);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10',
user: updatedUser,
);
final response = await packsApiV2.getPack(request, '10');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await packsApiV2.getPack(request, '10');
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('10'));
// isPurchased may be false since pack id=10 is public, so just check it exists
expect(responseBody.containsKey('isPurchased'), isTrue);
});
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('10'));
// isPurchased may be false since pack id=10 is public, so just check it exists
expect(responseBody.containsKey('isPurchased'), isTrue);
},
);
test('should return 404 for non-existent pack', () async {
final request = buildRequest(
@ -346,45 +334,48 @@ void main() {
);
final response = await packsApiV2.getPack(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should return buy page for unauthenticated private pack access', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11',
);
test(
'should return buy page for unauthenticated private pack access',
() async {
final request = buildRequest('GET', 'http://localhost/api/v2/packs/11');
final response = await packsApiV2.getPack(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await packsApiV2.getPack(request, '11');
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
});
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
},
);
test('should return buy page for authenticated user without pack access', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11',
user: testUser,
);
test(
'should return buy page for authenticated user without pack access',
() async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11',
user: testUser,
);
final response = await packsApiV2.getPack(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await packsApiV2.getPack(request, '11');
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
});
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
},
);
});
group('PacksApiV2 - Get Pack Cards', () {
@ -396,20 +387,19 @@ void main() {
);
final response = await packsApiV2.getPackCards(request, '10');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['items'], isA<List>());
expect(responseBody['total'], isA<int>());
expect(responseBody['page'], equals(1));
expect(responseBody['limit'], equals(10));
final items = responseBody['items'] as List;
expect(items.length, greaterThanOrEqualTo(0));
});
test('should return 404 for non-existent pack', () async {
final request = buildRequest(
'GET',
@ -418,8 +408,8 @@ void main() {
);
final response = await packsApiV2.getPackCards(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -433,8 +423,8 @@ void main() {
);
final response = await packsApiV2.getPackCards(request, '10');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -450,8 +440,8 @@ void main() {
);
final response = await packsApiV2.getCardImage(request, '10', '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -465,8 +455,8 @@ void main() {
);
final response = await packsApiV2.getCardImage(request, '999', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -480,8 +470,8 @@ void main() {
);
final response = await packsApiV2.getCardImage(request, '10', 'invalid');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -495,8 +485,8 @@ void main() {
);
final response = await packsApiV2.getCardImage(request, 'invalid', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -524,28 +514,31 @@ void main() {
);
final response = await packsApiV2.getCardImage(request, '99', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should allow access without authentication for enabled packs', () async {
// Request without user context (public access)
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/1/image',
);
test(
'should allow access without authentication for enabled packs',
() async {
// Request without user context (public access)
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/1/image',
);
// This should work even without auth because pack 10 is enabled
// and images are now accessible for enabled packs
final response = await packsApiV2.getCardImage(request, '10', '1');
// Note: This might return 404 if image file doesn't exist,
// but it should not return 401 Unauthorized
expect(response.statusCode, isNot(equals(401)));
});
// This should work even without auth because pack 10 is enabled
// and images are now accessible for enabled packs
final response = await packsApiV2.getCardImage(request, '10', '1');
// Note: This might return 404 if image file doesn't exist,
// but it should not return 401 Unauthorized
expect(response.statusCode, isNot(equals(401)));
},
);
test('should return 404 if card does not belong to pack', () async {
// Create another pack and card
@ -580,8 +573,8 @@ void main() {
);
final response = await packsApiV2.getCardImage(request, '10', '3');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -597,8 +590,8 @@ void main() {
);
final response = await packsApiV2.getCardImageBack(request, '10', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -612,8 +605,8 @@ void main() {
);
final response = await packsApiV2.getCardImageBack(request, '10', '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -627,8 +620,8 @@ void main() {
);
final response = await packsApiV2.getCardImageBack(request, '999', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -646,8 +639,8 @@ void main() {
'10',
'invalid',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -665,8 +658,8 @@ void main() {
'invalid',
'1',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -680,28 +673,31 @@ void main() {
);
final response = await packsApiV2.getCardImageBack(request, '99', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should allow access without authentication for enabled packs', () async {
// Request without user context (public access)
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/1/imageBack',
);
test(
'should allow access without authentication for enabled packs',
() async {
// Request without user context (public access)
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/1/imageBack',
);
// This should work even without auth because pack 10 is enabled
// and images are now accessible for enabled packs
final response = await packsApiV2.getCardImageBack(request, '10', '1');
// Note: This might return 404 if image file doesn't exist,
// but it should not return 401 Unauthorized
expect(response.statusCode, isNot(equals(401)));
});
// This should work even without auth because pack 10 is enabled
// and images are now accessible for enabled packs
final response = await packsApiV2.getCardImageBack(request, '10', '1');
// Note: This might return 404 if image file doesn't exist,
// but it should not return 401 Unauthorized
expect(response.statusCode, isNot(equals(401)));
},
);
test('should return 404 if card does not belong to pack', () async {
// Try to get card 3 from pack 10 (card 3 belongs to pack 20)
@ -711,8 +707,8 @@ void main() {
);
final response = await packsApiV2.getCardImageBack(request, '10', '3');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -727,43 +723,46 @@ void main() {
);
final response = await packsApiV2.getPackTests(request, '10');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
});
test('should return tests list for authenticated user with pack access', () async {
// First, add pack to user so they have access
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(10);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
test(
'should return tests list for authenticated user with pack access',
() async {
// First, add pack to user so they have access
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(10);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
}
}
}
});
});
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/tests',
user: updatedUser,
);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/tests',
user: updatedUser,
);
final response = await packsApiV2.getPackTests(request, '10');
final responseBody = jsonDecode(await response.readAsString());
final response = await packsApiV2.getPackTests(request, '10');
final responseBody = jsonDecode(await response.readAsString());
expect(response.statusCode, equals(200));
expect(responseBody, isA<List>());
});
expect(response.statusCode, equals(200));
expect(responseBody, isA<List>());
},
);
test('should return 404 for non-existent pack', () async {
final request = buildRequest(
@ -773,8 +772,8 @@ void main() {
);
final response = await packsApiV2.getPackTests(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -789,8 +788,8 @@ void main() {
);
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
@ -798,55 +797,61 @@ void main() {
expect(responseBody.containsKey('cards'), isTrue);
});
test('should return buy page for authenticated user without pack', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
user: testUser,
);
test(
'should return buy page for authenticated user without pack',
() async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
user: testUser,
);
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
});
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['price'], equals('199'));
expect(responseBody.containsKey('cards'), isTrue);
},
);
test('should return pack info with isPurchased=true for authenticated user who already owns pack', () async {
// First, add pack to user
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(11);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
test(
'should return pack info with isPurchased=true for authenticated user who already owns pack',
() async {
// First, add pack to user
await testIsar.writeTxn(() async {
final user = await testIsar.userModels.get(1);
if (user != null) {
await user.packs.load();
final pack = await testIsar.cardPackModels.get(11);
if (pack != null) {
user.packs.add(pack);
await user.packs.save();
}
}
}
});
});
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
// Reload user to get updated packs
final updatedUser = await testIsar.userModels.get(1);
expect(updatedUser, isNotNull);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
user: updatedUser,
);
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/11/buy',
user: updatedUser,
);
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await packsApiV2.getPackBuyPage(request, '11');
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['isPurchased'], equals(true));
});
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('11'));
expect(responseBody['isPurchased'], equals(true));
},
);
test('should return 404 for non-existent pack (unauthenticated)', () async {
final request = buildRequest(
@ -855,8 +860,8 @@ void main() {
);
final response = await packsApiV2.getPackBuyPage(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -870,8 +875,8 @@ void main() {
);
final response = await packsApiV2.getPackBuyPage(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -884,12 +889,11 @@ void main() {
);
final response = await packsApiV2.getPackBuyPage(request, 'invalid');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
});
});
}

View file

@ -26,18 +26,16 @@ void main() {
late UserModel testUser;
late UserDataModel testUserData;
Request buildRequest(
String method,
String url, {
UserModel? user,
}) {
Request buildRequest(String method, String url, {UserModel? user}) {
final context = <String, Object?>{};
if (user != null) {
context['user'] = user;
}
final uri = url.startsWith('http') ? Uri.parse(url) : Uri.parse('http://localhost$url');
final uri = url.startsWith('http')
? Uri.parse(url)
: Uri.parse('http://localhost$url');
return Request(method, uri).change(context: context);
}
@ -107,10 +105,7 @@ void main() {
await testIsar.userModels.put(user);
testUser = user;
final userData = UserDataModel(
tags: ['premium', 'beta'],
words: [],
);
final userData = UserDataModel(tags: ['premium', 'beta'], words: []);
await testIsar.userDataModels.put(userData);
userData.user.value = user;
await userData.user.save();
@ -137,7 +132,8 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(401));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], equals('unauthorized'));
});
@ -146,7 +142,8 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['campaigns'], isA<List>());
expect((body['campaigns'] as List).isEmpty, isTrue);
});
@ -184,11 +181,13 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['campaigns'], isA<List>());
expect((body['campaigns'] as List).length, equals(1));
final campaign = (body['campaigns'] as List).first as Map<String, dynamic>;
final campaign =
(body['campaigns'] as List).first as Map<String, dynamic>;
expect(campaign['status'], equals('active'));
expect(campaign['promoCodes'], isA<List>());
expect((campaign['promoCodes'] as List).length, equals(2));
@ -238,10 +237,14 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
expect((campaigns.first as Map<String, dynamic>)['template'], equals('ACTIVE'));
expect(
(campaigns.first as Map<String, dynamic>)['template'],
equals('ACTIVE'),
);
});
test('filters out campaigns outside date range', () async {
@ -300,10 +303,14 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
expect((campaigns.first as Map<String, dynamic>)['template'], equals('ACTIVE'));
expect(
(campaigns.first as Map<String, dynamic>)['template'],
equals('ACTIVE'),
);
});
test('filters campaigns by user tags', () async {
@ -368,10 +375,13 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(2));
final templates = campaigns.map((c) => (c as Map<String, dynamic>)['template']).toList();
final templates = campaigns
.map((c) => (c as Map<String, dynamic>)['template'])
.toList();
expect(templates, contains('MATCHING'));
expect(templates, contains('NOTAGS'));
expect(templates, isNot(contains('NONMATCHING')));
@ -411,7 +421,8 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.isEmpty, isTrue);
});
@ -435,8 +446,14 @@ void main() {
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode1 = PromoCodeModel(code: 'LIMITED1', activations: 1); // Already used
final promoCode2 = PromoCodeModel(code: 'LIMITED2', activations: 0); // Available
final promoCode1 = PromoCodeModel(
code: 'LIMITED1',
activations: 1,
); // Already used
final promoCode2 = PromoCodeModel(
code: 'LIMITED2',
activations: 0,
); // Available
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
@ -447,7 +464,8 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
@ -493,7 +511,8 @@ void main() {
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
@ -503,61 +522,76 @@ void main() {
expect(promoCodes.first, equals('AVAILABLE1'));
});
test('returns proper JSON format with campaign and code information', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
test(
'returns proper JSON format with campaign and code information',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'FORMAT',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
name: 'Test Campaign',
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'FORMAT',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
name: 'Test Campaign',
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'FORMAT123');
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes',
user: testUser,
);
await testIsar.promoCodesCampaignModels.put(campaign);
final response = await promocodesApiV2.listPromocodes(request);
final promoCode = PromoCodeModel(code: 'FORMAT123');
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body.containsKey('campaigns'), isTrue);
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body.containsKey('campaigns'), isTrue);
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
final campaign = campaigns.first as Map<String, dynamic>;
expect(campaign.containsKey('id'), isTrue);
expect(campaign.containsKey('name'), isTrue);
expect(campaign.containsKey('status'), isTrue);
expect(campaign.containsKey('start'), isTrue);
expect(campaign.containsKey('finish'), isTrue);
expect(campaign.containsKey('promoCodes'), isTrue);
expect(campaign['status'], equals('active'));
expect(campaign['promoCodes'], isA<List>());
});
final campaign = campaigns.first as Map<String, dynamic>;
expect(campaign.containsKey('id'), isTrue);
expect(campaign.containsKey('name'), isTrue);
expect(campaign.containsKey('status'), isTrue);
expect(campaign.containsKey('start'), isTrue);
expect(campaign.containsKey('finish'), isTrue);
expect(campaign.containsKey('promoCodes'), isTrue);
expect(campaign['status'], equals('active'));
expect(campaign['promoCodes'], isA<List>());
},
);
});
group('GET /api/v2/promocodes/{code}/validate', () {
test('returns 401 for unauthenticated requests', () async {
final request = buildRequest('GET', '/api/v2/promocodes/TEST123/validate');
final response = await promocodesApiV2.validatePromocode(request, 'TEST123');
final request = buildRequest(
'GET',
'/api/v2/promocodes/TEST123/validate',
);
final response = await promocodesApiV2.validatePromocode(
request,
'TEST123',
);
expect(response.statusCode, equals(401));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], equals('unauthorized'));
});
@ -567,10 +601,14 @@ void main() {
'/api/v2/promocodes/NONEXISTENT/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'NONEXISTENT');
final response = await promocodesApiV2.validatePromocode(
request,
'NONEXISTENT',
);
expect(response.statusCode, equals(404));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод не найден'));
});
@ -584,7 +622,8 @@ void main() {
final response = await promocodesApiV2.validatePromocode(request, '');
expect(response.statusCode, equals(400));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], equals('bad_request'));
});
@ -618,10 +657,14 @@ void main() {
'/api/v2/promocodes/VALID123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'VALID123');
final response = await promocodesApiV2.validatePromocode(
request,
'VALID123',
);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
@ -656,10 +699,14 @@ void main() {
'/api/v2/promocodes/INACTIVE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'INACTIVE123');
final response = await promocodesApiV2.validatePromocode(
request,
'INACTIVE123',
);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод недействителен'));
});
@ -694,10 +741,14 @@ void main() {
'/api/v2/promocodes/FUTURE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'FUTURE123');
final response = await promocodesApiV2.validatePromocode(
request,
'FUTURE123',
);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод еще не активен'));
});
@ -732,176 +783,211 @@ void main() {
'/api/v2/promocodes/EXPIRED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'EXPIRED123');
final response = await promocodesApiV2.validatePromocode(
request,
'EXPIRED123',
);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод истек'));
});
test('returns valid: false for promocode that reached activation limit', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
test(
'returns valid: false for promocode that reached activation limit',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'EXHAUSTED',
products: [],
activationsPerCode: 1,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'EXHAUSTED',
products: [],
activationsPerCode: 1,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(
code: 'EXHAUSTED123',
activations: 1,
);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/EXHAUSTED123/validate',
user: testUser,
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'EXHAUSTED123', activations: 1);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/EXHAUSTED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'EXHAUSTED123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод исчерпан'));
});
test('returns valid: false for promocode already activated by user', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'USED',
products: [],
activationsPerCode: 10,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
final response = await promocodesApiV2.validatePromocode(
request,
'EXHAUSTED123',
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'USED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод исчерпан'));
},
);
// User has already activated this code
testUserData.activatedPromoCodes.add(promoCode);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
test(
'returns valid: false for promocode already activated by user',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
final request = buildRequest(
'GET',
'/api/v2/promocodes/USED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'USED123');
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'USED',
products: [],
activationsPerCode: 10,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод уже был активирован'));
});
final promoCode = PromoCodeModel(code: 'USED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
test('returns valid: false when user reached campaign activation limit', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
// User has already activated this code
testUserData.activatedPromoCodes.add(promoCode);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'LIMITED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1, // User can only activate once
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
final request = buildRequest(
'GET',
'/api/v2/promocodes/USED123/validate',
user: testUser,
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode1 = PromoCodeModel(code: 'LIMITED1', activations: 0);
final promoCode2 = PromoCodeModel(code: 'LIMITED2', activations: 0);
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
await campaign.promoCodes.save();
// User has already activated one code from this campaign
testUserData.activatedPromoCodes.add(promoCode1);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/LIMITED2/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'LIMITED2');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Вы уже участвовали в этой акции'));
});
test('returns valid: false when user tags do not match campaign tags', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'TAGGED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: ['vip'], // User has ['premium', 'beta'], no 'vip'
final response = await promocodesApiV2.validatePromocode(
request,
'USED123',
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'TAGGED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод уже был активирован'));
},
);
final request = buildRequest(
'GET',
'/api/v2/promocodes/TAGGED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'TAGGED123');
test(
'returns valid: false when user reached campaign activation limit',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод недействителен'));
});
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'LIMITED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1, // User can only activate once
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode1 = PromoCodeModel(code: 'LIMITED1', activations: 0);
final promoCode2 = PromoCodeModel(code: 'LIMITED2', activations: 0);
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
await campaign.promoCodes.save();
// User has already activated one code from this campaign
testUserData.activatedPromoCodes.add(promoCode1);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/LIMITED2/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(
request,
'LIMITED2',
);
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Вы уже участвовали в этой акции'));
},
);
test(
'returns valid: false when user tags do not match campaign tags',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'TAGGED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: ['vip'], // User has ['premium', 'beta'], no 'vip'
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'TAGGED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/TAGGED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(
request,
'TAGGED123',
);
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод недействителен'));
},
);
test('returns valid: true when user tags match campaign tags', () async {
final now = DateTime.now();
@ -933,118 +1019,139 @@ void main() {
'/api/v2/promocodes/MATCHING123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'MATCHING123');
final response = await promocodesApiV2.validatePromocode(
request,
'MATCHING123',
);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
test('returns valid: false for individual promocode for another user', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
test(
'returns valid: false for individual promocode for another user',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
late UserModel otherUser;
late UserDataModel otherUserData;
late UserModel otherUser;
late UserDataModel otherUserData;
await testIsar.writeTxn(() async {
// Create another user
otherUser = UserModel.empty.copyWith(
id: 2,
name: 'Other User',
email: 'other@example.com',
await testIsar.writeTxn(() async {
// Create another user
otherUser = UserModel.empty.copyWith(
id: 2,
name: 'Other User',
email: 'other@example.com',
);
await testIsar.userModels.put(otherUser);
otherUserData = UserDataModel(tags: [], words: []);
await testIsar.userDataModels.put(otherUserData);
otherUserData.user.value = otherUser;
await otherUserData.user.save();
otherUser.userData.value = otherUserData;
await otherUser.userData.save();
final campaign = PromoCodesCampaignModel(
template: 'INDIVIDUAL',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(
code: 'INDIVIDUAL123',
activations: 0,
);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
// Assign promocode to other user
promoCode.userData.value = otherUserData;
await promoCode.userData.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/INDIVIDUAL123/validate',
user: testUser,
);
await testIsar.userModels.put(otherUser);
otherUserData = UserDataModel(
tags: [],
words: [],
final response = await promocodesApiV2.validatePromocode(
request,
'INDIVIDUAL123',
);
await testIsar.userDataModels.put(otherUserData);
otherUserData.user.value = otherUser;
await otherUserData.user.save();
otherUser.userData.value = otherUserData;
await otherUser.userData.save();
final campaign = PromoCodesCampaignModel(
template: 'INDIVIDUAL',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(
body['message'],
equals('Это промокод для другого пользователя'),
);
await testIsar.promoCodesCampaignModels.put(campaign);
},
);
final promoCode = PromoCodeModel(code: 'INDIVIDUAL123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
test(
'returns valid: true for individual promocode for current user',
() async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
// Assign promocode to other user
promoCode.userData.value = otherUserData;
await promoCode.userData.save();
});
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'MYCODE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final request = buildRequest(
'GET',
'/api/v2/promocodes/INDIVIDUAL123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'INDIVIDUAL123');
final promoCode = PromoCodeModel(code: 'MYCODE123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Это промокод для другого пользователя'));
});
// Assign promocode to current user
promoCode.userData.value = testUserData;
await promoCode.userData.save();
});
test('returns valid: true for individual promocode for current user', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'MYCODE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
final request = buildRequest(
'GET',
'/api/v2/promocodes/MYCODE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(
request,
'MYCODE123',
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'MYCODE123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
// Assign promocode to current user
promoCode.userData.value = testUserData;
await promoCode.userData.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/MYCODE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'MYCODE123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
expect(response.statusCode, equals(200));
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
},
);
test('handles case-insensitive promocode', () async {
final now = DateTime.now();
@ -1077,10 +1184,14 @@ void main() {
'/api/v2/promocodes/case123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'case123');
final response = await promocodesApiV2.validatePromocode(
request,
'case123',
);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final body =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});

View file

@ -30,7 +30,7 @@ void main() {
// Initialize Isar for testing
await Isar.initializeIsarCore(download: true);
final testDir = Directory.systemTemp.createTempSync('isar_test_');
testIsar = await Isar.open(
[
CardPackModelSchema,
@ -55,7 +55,7 @@ void main() {
name: 'test_db',
inspector: false,
);
backend_main.isar = testIsar;
});
@ -64,13 +64,10 @@ void main() {
final discountsManager = const DiscountsManager();
final productsPriceResolver = ProductsPriceResolver(discountsManager);
final adsManager = AdsManager();
packDtoConverter = PackDtoConverter(
productsPriceResolver,
adsManager,
);
packDtoConverter = PackDtoConverter(productsPriceResolver, adsManager);
packManager = PackManager(packDtoConverter);
final subscriptionManager = SubscriptionManager();
final yooMoneyHandler = YooMoneyHandler(packManager);
final rustorePurchaseHandler = RustorePurchaseHandler();
@ -81,7 +78,7 @@ void main() {
rustorePurchaseHandler,
productsPriceResolver,
);
purchasesApiV2 = PurchasesApiV2(paymentManager, packManager);
// Create test user
@ -141,8 +138,8 @@ void main() {
);
final response = await purchasesApiV2.createPackPurchase(request, '20');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -155,8 +152,8 @@ void main() {
).change(context: {'user': testUser});
final response = await purchasesApiV2.createPackPurchase(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -185,8 +182,8 @@ void main() {
).change(context: {'user': updatedUser!});
final response = await purchasesApiV2.createPackPurchase(request, '20');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -205,8 +202,8 @@ void main() {
request,
'20',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['packId'], equals('20'));
@ -241,8 +238,8 @@ void main() {
request,
'20',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['isPurchased'], isTrue);
@ -259,8 +256,8 @@ void main() {
request,
'20',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -276,8 +273,8 @@ void main() {
request,
'invalid',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -289,15 +286,12 @@ void main() {
final request = Request(
'POST',
Uri.parse('http://localhost/api/v2/purchases/payments'),
body: jsonEncode({
'productId': '20',
'productType': 'pack',
}),
body: jsonEncode({'productId': '20', 'productType': 'pack'}),
);
final response = await purchasesApiV2.createPayment(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -307,14 +301,12 @@ void main() {
final request = Request(
'POST',
Uri.parse('http://localhost/api/v2/purchases/payments'),
body: jsonEncode({
'productType': 'pack',
}),
body: jsonEncode({'productType': 'pack'}),
).change(context: {'user': testUser});
final response = await purchasesApiV2.createPayment(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -329,8 +321,8 @@ void main() {
).change(context: {'user': testUser});
final response = await purchasesApiV2.createPayment(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -351,8 +343,8 @@ void main() {
request,
'payment123',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -370,8 +362,8 @@ void main() {
request,
'payment123',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -392,13 +384,13 @@ void main() {
request,
'payment123',
);
// Should return a response (either success or failure)
expect(response.statusCode, isIn([200, 500]));
if (response.statusCode == 200) {
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(responseBody['paymentId'], equals('payment123'));
expect(responseBody['status'], isA<String>());
expect(responseBody['result'], isA<bool>());
@ -406,4 +398,3 @@ void main() {
});
});
}

View file

@ -87,20 +87,23 @@ void main() {
});
group('SubscriptionsApiV2 - Get Plans', () {
test('should return 200 with empty array when no plans available', () async {
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/subscriptions/plans'),
);
test(
'should return 200 with empty array when no plans available',
() async {
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/subscriptions/plans'),
);
final response = await subscriptionsApiV2.getPlans(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final response = await subscriptionsApiV2.getPlans(request);
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['plans'], isA<List>());
expect(responseBody['plans'], isEmpty);
});
expect(response.statusCode, equals(200));
expect(responseBody['plans'], isA<List>());
expect(responseBody['plans'], isEmpty);
},
);
test('should return 200 with list of plans when plans exist', () async {
// Create test subscription plans
@ -147,8 +150,8 @@ void main() {
);
final response = await subscriptionsApiV2.getPlans(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['plans'], isA<List>());
@ -194,8 +197,8 @@ void main() {
).change(context: {'user': testUser});
final response = await subscriptionsApiV2.getPlans(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['plans'], isA<List>());
@ -228,8 +231,8 @@ void main() {
);
final response = await subscriptionsApiV2.getPlans(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(response.headers['content-type'], contains('application/json'));
@ -249,57 +252,56 @@ void main() {
expect(plan.containsKey('ui'), isTrue);
});
test('should handle multiple plans with different payment systems', () async {
// Create test subscription plans with different payment systems
await testIsar.writeTxn(() async {
final plan1 = SubscriptionPlanModel(
id: 1,
price: '299',
currency: 'RUB',
durationDays: 30,
features: [SubscriptionFeatureEnum.packs],
paymentSystem: PaymentSystem.yookassa,
paymentId: 'plan_1',
ui: SubscriptionPlanUI(
title: 'YooKassa Plan',
),
test(
'should handle multiple plans with different payment systems',
() async {
// Create test subscription plans with different payment systems
await testIsar.writeTxn(() async {
final plan1 = SubscriptionPlanModel(
id: 1,
price: '299',
currency: 'RUB',
durationDays: 30,
features: [SubscriptionFeatureEnum.packs],
paymentSystem: PaymentSystem.yookassa,
paymentId: 'plan_1',
ui: SubscriptionPlanUI(title: 'YooKassa Plan'),
);
await testIsar.subscriptionPlanModels.put(plan1);
final plan2 = SubscriptionPlanModel(
id: 2,
price: '399',
currency: 'RUB',
durationDays: 30,
features: [SubscriptionFeatureEnum.packs],
paymentSystem: PaymentSystem.google,
paymentId: 'plan_2',
ui: SubscriptionPlanUI(title: 'Google Play Plan'),
);
await testIsar.subscriptionPlanModels.put(plan2);
});
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/subscriptions/plans'),
);
await testIsar.subscriptionPlanModels.put(plan1);
final plan2 = SubscriptionPlanModel(
id: 2,
price: '399',
currency: 'RUB',
durationDays: 30,
features: [SubscriptionFeatureEnum.packs],
paymentSystem: PaymentSystem.google,
paymentId: 'plan_2',
ui: SubscriptionPlanUI(
title: 'Google Play Plan',
),
);
await testIsar.subscriptionPlanModels.put(plan2);
});
final response = await subscriptionsApiV2.getPlans(request);
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/subscriptions/plans'),
);
expect(response.statusCode, equals(200));
expect(responseBody['plans'], hasLength(2));
final response = await subscriptionsApiV2.getPlans(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['plans'], hasLength(2));
final plans = responseBody['plans'] as List;
final paymentSystems = plans
.map((p) => (p as Map<String, dynamic>)['paymentSystem'])
.toList();
expect(paymentSystems, contains('yookassa'));
expect(paymentSystems, contains('google'));
});
final plans = responseBody['plans'] as List;
final paymentSystems = plans
.map((p) => (p as Map<String, dynamic>)['paymentSystem'])
.toList();
expect(paymentSystems, contains('yookassa'));
expect(paymentSystems, contains('google'));
},
);
});
group('SubscriptionsApiV2 - Get Status', () {
@ -310,8 +312,8 @@ void main() {
);
final response = await subscriptionsApiV2.getStatus(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));
@ -325,8 +327,8 @@ void main() {
).change(context: {'user': testUser});
final response = await subscriptionsApiV2.getStatus(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['active'], isFalse);
@ -334,52 +336,55 @@ void main() {
expect(responseBody.containsKey('finish'), isFalse);
});
test('should return active: true with dates when subscription is active', () async {
final now = DateTime.now();
final startDate = now.subtract(const Duration(days: 5));
final finishDate = now.add(const Duration(days: 25));
test(
'should return active: true with dates when subscription is active',
() async {
final now = DateTime.now();
final startDate = now.subtract(const Duration(days: 5));
final finishDate = now.add(const Duration(days: 25));
// Create active subscription for test user
await testIsar.writeTxn(() async {
final subscription = UserSubscriptionModel(
start: startDate,
finish: finishDate,
features: [SubscriptionFeatureEnum.packs],
);
await testIsar.userSubscriptionModels.put(subscription);
final user = await testIsar.userModels.get(1);
if (user != null) {
user.subscriptionModel.value = subscription;
await user.subscriptionModel.save();
}
});
// Create active subscription for test user
await testIsar.writeTxn(() async {
final subscription = UserSubscriptionModel(
start: startDate,
finish: finishDate,
features: [SubscriptionFeatureEnum.packs],
);
await testIsar.userSubscriptionModels.put(subscription);
// Reload user with subscription
final userWithSubscription = await testIsar.userModels.get(1);
expect(userWithSubscription, isNotNull);
await userWithSubscription!.subscriptionModel.load();
final user = await testIsar.userModels.get(1);
if (user != null) {
user.subscriptionModel.value = subscription;
await user.subscriptionModel.save();
}
});
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/subscriptions/status'),
).change(context: {'user': userWithSubscription});
// Reload user with subscription
final userWithSubscription = await testIsar.userModels.get(1);
expect(userWithSubscription, isNotNull);
await userWithSubscription!.subscriptionModel.load();
final response = await subscriptionsApiV2.getStatus(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final request = Request(
'GET',
Uri.parse('http://localhost/api/v2/subscriptions/status'),
).change(context: {'user': userWithSubscription});
expect(response.statusCode, equals(200));
expect(responseBody['active'], isTrue);
expect(responseBody.containsKey('start'), isTrue);
expect(responseBody.containsKey('finish'), isTrue);
final start = DateTime.parse(responseBody['start'] as String);
final finish = DateTime.parse(responseBody['finish'] as String);
expect(start, equals(startDate));
expect(finish, equals(finishDate));
});
final response = await subscriptionsApiV2.getStatus(request);
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['active'], isTrue);
expect(responseBody.containsKey('start'), isTrue);
expect(responseBody.containsKey('finish'), isTrue);
final start = DateTime.parse(responseBody['start'] as String);
final finish = DateTime.parse(responseBody['finish'] as String);
expect(start, equals(startDate));
expect(finish, equals(finishDate));
},
);
test('should return active: false when subscription is expired', () async {
final now = DateTime.now();
@ -394,7 +399,7 @@ void main() {
features: [SubscriptionFeatureEnum.packs],
);
await testIsar.userSubscriptionModels.put(subscription);
final user = await testIsar.userModels.get(1);
if (user != null) {
user.subscriptionModel.value = subscription;
@ -413,8 +418,8 @@ void main() {
).change(context: {'user': userWithSubscription});
final response = await subscriptionsApiV2.getStatus(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['active'], isFalse);
@ -430,10 +435,13 @@ void main() {
final subscription = UserSubscriptionModel(
start: startDate,
finish: finishDate,
features: [SubscriptionFeatureEnum.packs, SubscriptionFeatureEnum.ads],
features: [
SubscriptionFeatureEnum.packs,
SubscriptionFeatureEnum.ads,
],
);
await testIsar.userSubscriptionModels.put(subscription);
final user = await testIsar.userModels.get(1);
if (user != null) {
user.subscriptionModel.value = subscription;
@ -451,20 +459,20 @@ void main() {
).change(context: {'user': userWithSubscription});
final response = await subscriptionsApiV2.getStatus(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(response.headers['content-type'], contains('application/json'));
expect(responseBody.containsKey('active'), isTrue);
expect(responseBody['active'], isA<bool>());
if (responseBody['active'] as bool) {
expect(responseBody.containsKey('start'), isTrue);
expect(responseBody.containsKey('finish'), isTrue);
expect(responseBody['start'], isA<String>());
expect(responseBody['finish'], isA<String>());
// Verify ISO8601 format
expect(
() => DateTime.parse(responseBody['start'] as String),

View file

@ -28,7 +28,7 @@ void main() {
// Initialize Isar for testing
await Isar.initializeIsarCore(download: true);
final testDir = Directory.systemTemp.createTempSync('isar_test_');
testIsar = await Isar.open(
[
CardPackModelSchema,
@ -53,7 +53,7 @@ void main() {
name: 'test_db',
inspector: false,
);
backend_main.isar = testIsar;
});
@ -62,16 +62,13 @@ void main() {
final discountsManager = const DiscountsManager();
final productsPriceResolver = ProductsPriceResolver(discountsManager);
final adsManager = AdsManager();
packDtoConverter = PackDtoConverter(
productsPriceResolver,
adsManager,
);
packDtoConverter = PackDtoConverter(productsPriceResolver, adsManager);
testManager = TestManager(packDtoConverter);
final freePacksDistributor = const FreePacksDistributor();
userManager = UserManager(freePacksDistributor);
testsApiV2 = TestsApiV2(testManager, userManager);
// Create test user
@ -93,11 +90,7 @@ void main() {
// Create test model
await testIsar.writeTxn(() async {
final test = TestModel(
id: 100,
name: 'Test Test',
version: '1.0',
);
final test = TestModel(id: 100, name: 'Test Test', version: '1.0');
await testIsar.testModels.put(test);
});
});
@ -125,8 +118,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTest(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['id'], equals('100'));
@ -140,8 +133,8 @@ void main() {
);
final response = await testsApiV2.getTest(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -154,8 +147,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTest(request, '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
@ -168,8 +161,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTest(request, 'invalid');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -200,8 +193,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.submitTestResults(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['success'], equals(true));
@ -222,8 +215,8 @@ void main() {
);
final response = await testsApiV2.submitTestResults(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -243,8 +236,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.submitTestResults(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -259,12 +252,15 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.submitTestResults(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
expect(responseBody['message'], contains('Invalid test statistics format'));
expect(
responseBody['message'],
contains('Invalid test statistics format'),
);
});
test('should return 400 for empty request body', () async {
@ -275,8 +271,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.submitTestResults(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -297,8 +293,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.submitTestResults(request, 'invalid');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -313,8 +309,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTestHistory(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['items'], isA<List>());
@ -370,8 +366,8 @@ void main() {
).change(context: {'user': updatedUser!});
final response = await testsApiV2.getTestHistory(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['items'], isA<List>());
@ -411,8 +407,8 @@ void main() {
).change(context: {'user': updatedUser!});
final response = await testsApiV2.getTestHistory(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['items'].length, lessThanOrEqualTo(2));
@ -428,8 +424,8 @@ void main() {
);
final response = await testsApiV2.getTestHistory(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('Unauthorized'));
@ -442,8 +438,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTestHistory(request, 'invalid');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -456,8 +452,8 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTestHistory(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
@ -471,13 +467,15 @@ void main() {
).change(context: {'user': testUser});
final response = await testsApiV2.getTestHistory(request, '100');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
expect(responseBody['message'], contains('Limit must be between 1 and 100'));
expect(
responseBody['message'],
contains('Limit must be between 1 and 100'),
);
});
});
}

View file

@ -40,15 +40,9 @@ void main() {
UserModel? user,
String? body,
}) {
var request = Request(
method,
Uri.parse(url),
body: body,
);
var request = Request(method, Uri.parse(url), body: body);
if (user != null) {
request = request.change(
context: {'user': user},
);
request = request.change(context: {'user': user});
}
return request;
}
@ -88,7 +82,7 @@ void main() {
setUp(() async {
statisticsCalculator = StatisticsCalculator();
// Set up PaymentManager dependencies
final discountsManager = const DiscountsManager();
final productsPriceResolver = ProductsPriceResolver(discountsManager);
@ -108,18 +102,14 @@ void main() {
rustorePurchaseHandler,
productsPriceResolver,
);
userManager = UserManager(
const FreePacksDistributor(),
SessionTracker(testIsar),
statisticsCalculator,
AchievementManager(testIsar),
);
usersApiV2 = UsersApiV2(
userManager,
paymentManager,
statisticsCalculator,
);
usersApiV2 = UsersApiV2(userManager, paymentManager, statisticsCalculator);
// Create test user with statistics data
await testIsar.writeTxn(() async {
@ -191,7 +181,7 @@ void main() {
),
],
)..user.value = user;
// Save userData first
await testIsar.userDataModels.put(userData);
// Then link it to user
@ -199,7 +189,7 @@ void main() {
await user.userData.save();
// Save user again to persist the link
await testIsar.userModels.put(user);
// Reload user to ensure userData link is properly loaded
testUser = (await testIsar.userModels.get(1))!;
await testUser.userData.load();
@ -235,7 +225,7 @@ void main() {
if (fetchedUser != null) {
await fetchedUser.userData.load();
}
final request = buildRequest(
'GET',
'http://localhost/api/v2/users/me/statistics/detailed',
@ -243,8 +233,8 @@ void main() {
);
final response = await usersApiV2.getDetailedStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
// The endpoint should return 200 with UserDataDto or 404 if userData not found
if (response.statusCode == 404) {
@ -253,23 +243,23 @@ void main() {
expect(responseBody['error'], equals('user_data_not_found'));
return;
}
expect(response.statusCode, equals(200));
// Verify response structure - UserDataDto should have these fields
expect(responseBody, isA<Map>());
expect(responseBody.isNotEmpty, isTrue);
// Verify that response contains expected UserDataDto fields
// Note: Some fields may be null in JSON serialization
if (responseBody.containsKey('totalStudyTimeMinutes') &&
if (responseBody.containsKey('totalStudyTimeMinutes') &&
responseBody['totalStudyTimeMinutes'] != null) {
expect(responseBody['totalStudyTimeMinutes'], equals(180));
}
if (responseBody.containsKey('currentStreak') &&
if (responseBody.containsKey('currentStreak') &&
responseBody['currentStreak'] != null) {
expect(responseBody['currentStreak'], equals(3));
}
if (responseBody.containsKey('longestStreak') &&
if (responseBody.containsKey('longestStreak') &&
responseBody['longestStreak'] != null) {
expect(responseBody['longestStreak'], equals(5));
}
@ -300,8 +290,8 @@ void main() {
);
final response = await usersApiV2.getDetailedStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));
@ -315,8 +305,8 @@ void main() {
);
final response = await usersApiV2.getDetailedStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('user_data_not_found'));
@ -394,8 +384,8 @@ void main() {
);
final response = await usersApiV2.getPacksStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));
@ -411,8 +401,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['words'], isA<List>());
@ -431,8 +421,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect((responseBody['words'] as List).length, equals(2));
@ -446,8 +436,8 @@ void main() {
);
final response2 = await usersApiV2.getWordsStatistics(request2);
final responseBody2 = jsonDecode(await response2.readAsString())
as Map<String, dynamic>;
final responseBody2 =
jsonDecode(await response2.readAsString()) as Map<String, dynamic>;
expect(responseBody2['totalCount'], equals(3));
expect((responseBody2['words'] as List).length, equals(1));
@ -462,8 +452,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
final words = responseBody['words'] as List;
@ -476,10 +466,15 @@ void main() {
expect(word['incorrect'], isA<double>());
}
// Verify sorting: check that difficulty scores are in descending order
final difficulties = words.map((w) => w['difficultyScore'] as double).toList();
final difficulties = words
.map((w) => w['difficultyScore'] as double)
.toList();
for (var i = 0; i < difficulties.length - 1; i++) {
expect(difficulties[i], greaterThanOrEqualTo(difficulties[i + 1]),
reason: 'Words should be sorted by difficulty descending');
expect(
difficulties[i],
greaterThanOrEqualTo(difficulties[i + 1]),
reason: 'Words should be sorted by difficulty descending',
);
}
});
@ -491,8 +486,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
final words = responseBody['words'] as List;
@ -508,8 +503,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['words'], isA<List>());
@ -523,8 +518,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
final words = responseBody['words'] as List;
@ -541,8 +536,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['pageSize'], equals(50)); // Default limit
@ -557,8 +552,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['pageSize'], lessThanOrEqualTo(100));
@ -572,8 +567,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['page'], equals(0)); // Should clamp to 0
@ -587,8 +582,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['words'], isA<List>());
@ -604,8 +599,8 @@ void main() {
);
final response = await usersApiV2.getWordsStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));
@ -621,8 +616,8 @@ void main() {
);
final response = await usersApiV2.getTimelineStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['period'], equals('month'));
@ -643,8 +638,8 @@ void main() {
);
final response = await usersApiV2.getTimelineStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['period'], equals('week'));
@ -660,8 +655,8 @@ void main() {
);
final response = await usersApiV2.getTimelineStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['period'], isA<String>());
@ -675,8 +670,8 @@ void main() {
);
final response = await usersApiV2.getTimelineStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
// Should still return valid response, ignoring invalid date
expect(response.statusCode, equals(200));
@ -691,8 +686,8 @@ void main() {
);
final response = await usersApiV2.getTimelineStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['totalDays'], equals(0));
@ -713,8 +708,8 @@ void main() {
);
final response = await usersApiV2.getTimelineStatistics(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));
@ -735,8 +730,8 @@ void main() {
);
final response = await usersApiV2.recordStudySession(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(200));
expect(responseBody['result'], equals(true));
@ -752,8 +747,8 @@ void main() {
);
final response = await usersApiV2.recordStudySession(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('bad_request'));
@ -769,8 +764,8 @@ void main() {
);
final response = await usersApiV2.recordStudySession(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('bad_request'));
@ -786,8 +781,8 @@ void main() {
);
final response = await usersApiV2.recordStudySession(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('bad_request'));
@ -805,8 +800,8 @@ void main() {
);
final response = await usersApiV2.recordStudySession(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));
@ -854,8 +849,8 @@ void main() {
);
final response = await usersApiV2.getAchievements(request);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
final responseBody =
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(response.statusCode, equals(401));
expect(responseBody['error'], equals('unauthorized'));

View file

@ -6,7 +6,7 @@ import 'package:mnemo_cards_backend/database/tables/users.dart';
import 'package:mnemo_cards_backend/database/tables/packs.dart';
/// Unit тесты для SoftDeleteMixin
///
///
/// Тестирует функциональность soft delete на примере WordStatisticsDao
/// Требования:
/// - PostgreSQL должен быть запущен на localhost:5432
@ -19,7 +19,8 @@ void main() {
setUpAll(() async {
// Подключение к тестовой БД
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
@ -114,14 +115,14 @@ void main() {
expect(activeStats.length, equals(3));
// Удалить одну запись (soft delete)
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats2.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats2.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// selectActive должен вернуть только активные
activeStats = await db.wordStatisticsDao.selectActive().get();
@ -145,14 +146,14 @@ void main() {
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final activeStats = await db.wordStatisticsDao.selectActive().get();
expect(activeStats, isEmpty);
@ -162,7 +163,8 @@ void main() {
// Создать записи для разных пользователей
final user2 = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
externalUserId:
'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User 2'),
),
);
@ -185,32 +187,35 @@ void main() {
);
// selectActive с where должен фильтровать и по isDeleted, и по условию
final user1Stats = await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(testUserId)))
.get();
final user1Stats =
await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(testUserId)))
.get();
expect(user1Stats.length, equals(1));
expect(user1Stats.first.id, equals(stats1.id));
// Удалить запись пользователя 1
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats1.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats1.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// Теперь selectActive для пользователя 1 должен вернуть пустой список
final user1StatsAfterDelete = await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(testUserId)))
.get();
final user1StatsAfterDelete =
await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(testUserId)))
.get();
expect(user1StatsAfterDelete, isEmpty);
// Но запись пользователя 2 все еще активна
final user2Stats = await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(user2.id)))
.get();
final user2Stats =
await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(user2.id)))
.get();
expect(user2Stats.length, equals(1));
expect(user2Stats.first.id, equals(stats2.id));
});
@ -233,7 +238,9 @@ void main() {
});
test('возвращает null если запись не существует', () async {
final result = await db.wordStatisticsDao.getActiveById('non_existent_id');
final result = await db.wordStatisticsDao.getActiveById(
'non_existent_id',
);
expect(result, isNull);
});
@ -246,14 +253,14 @@ void main() {
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final result = await db.wordStatisticsDao.getActiveById(stats.id);
expect(result, isNull);
@ -268,14 +275,14 @@ void main() {
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// Проверить что запись физически существует в БД, но getActiveById не возвращает её
final allRecords = await db.select(db.wordStatistics).get();

View file

@ -12,12 +12,13 @@ void main() {
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database =
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ??
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username =
Platform.environment['TEST_DB_USER'] ??
Platform.environment['DB_USER'] ??
'mnemo_user';
final password = Platform.environment['TEST_DB_PASSWORD'] ??
final password =
Platform.environment['TEST_DB_PASSWORD'] ??
Platform.environment['DB_PASSWORD'] ??
'';
@ -44,9 +45,9 @@ void main() {
tearDown(() async {
// Keep cleanup scoped to generated tests only to avoid touching other data
// that might exist in the shared test DB.
final allGenerated = await (db.select(db.tests)
..where((t) => t.version.equals('generated')))
.get();
final allGenerated = await (db.select(
db.tests,
)..where((t) => t.version.equals('generated'))).get();
for (final t in allGenerated) {
await (db.delete(db.tests)..where((x) => x.id.equals(t.id))).go();
@ -84,53 +85,58 @@ void main() {
expect(stillThere, isNull);
});
test('hardDeleteOldSoftDeletedGeneratedTests removes old soft-deleted tests',
() async {
final now = DateTime.now();
test(
'hardDeleteOldSoftDeletedGeneratedTests removes old soft-deleted tests',
() async {
final now = DateTime.now();
// Create a pack so we have a normal relation entry.
final packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'pack for generated test cleanup',
subtitle: 'subtitle',
size: 1,
),
);
// Create a pack so we have a normal relation entry.
final packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'pack for generated test cleanup',
subtitle: 'subtitle',
size: 1,
),
);
// Create a generated test and link it to the pack.
final testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'linked generated test',
version: const Value('generated'),
createdAt: Value(PgDateTime(now.subtract(const Duration(days: 10)))),
updatedAt: Value(PgDateTime(now.subtract(const Duration(days: 10)))),
),
);
await db.testDao.linkTestToPack(testId, packId);
// Create a generated test and link it to the pack.
final testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'linked generated test',
version: const Value('generated'),
createdAt: Value(
PgDateTime(now.subtract(const Duration(days: 10))),
),
updatedAt: Value(
PgDateTime(now.subtract(const Duration(days: 10))),
),
),
);
await db.testDao.linkTestToPack(testId, packId);
// Soft delete it long ago so it's eligible for TTL purge.
await (db.update(db.tests)..where((t) => t.id.equals(testId))).write(
TestsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(now.subtract(const Duration(days: 9)))),
updatedAt: Value(PgDateTime(now.subtract(const Duration(days: 9)))),
),
);
// Soft delete it long ago so it's eligible for TTL purge.
await (db.update(db.tests)..where((t) => t.id.equals(testId))).write(
TestsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(now.subtract(const Duration(days: 9)))),
updatedAt: Value(PgDateTime(now.subtract(const Duration(days: 9)))),
),
);
final deleted = await db.testDao.hardDeleteOldSoftDeletedGeneratedTests(
olderThan: const Duration(days: 7),
);
final deleted = await db.testDao.hardDeleteOldSoftDeletedGeneratedTests(
olderThan: const Duration(days: 7),
);
expect(deleted, equals(1));
final stillThere = await db.testDao.getTestById(testId);
expect(stillThere, isNull);
expect(deleted, equals(1));
final stillThere = await db.testDao.getTestById(testId);
expect(stillThere, isNull);
// Cleanup the pack relation/pack.
await (db.delete(db.testPackRelations)
..where((r) => r.packId.equals(packId)))
.go();
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId))).go();
});
// Cleanup the pack relation/pack.
await (db.delete(
db.testPackRelations,
)..where((r) => r.packId.equals(packId))).go();
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId))).go();
},
);
});
}

View file

@ -16,12 +16,13 @@ void main() {
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database =
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ??
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username =
Platform.environment['TEST_DB_USER'] ??
Platform.environment['DB_USER'] ??
'mnemo_user';
final password = Platform.environment['TEST_DB_PASSWORD'] ??
final password =
Platform.environment['TEST_DB_PASSWORD'] ??
Platform.environment['DB_PASSWORD'] ??
'';
@ -40,13 +41,13 @@ void main() {
await Migrator(db).createAll();
// Initialize MinioService
final minioEndpoint = Platform.environment['MINIO_ENDPOINT'] ??
'localhost:9000';
final minioAccessKey = Platform.environment['MINIO_ACCESS_KEY'] ??
'minioadmin';
final minioSecretKey = Platform.environment['MINIO_SECRET_KEY'] ??
'minioadmin';
final minioEndpoint =
Platform.environment['MINIO_ENDPOINT'] ?? 'localhost:9000';
final minioAccessKey =
Platform.environment['MINIO_ACCESS_KEY'] ?? 'minioadmin';
final minioSecretKey =
Platform.environment['MINIO_SECRET_KEY'] ?? 'minioadmin';
minioService = MinioService(
endpoint: minioEndpoint,
accessKey: minioAccessKey,
@ -68,20 +69,22 @@ void main() {
tearDown(() async {
// Cleanup is scoped to the entities created by this test.
// Delete generated tests to avoid leaving orphans if pack is removed.
final generatedTests = await (db.select(db.tests)
..where((t) => t.version.equals('generated')))
.get();
final generatedTests = await (db.select(
db.tests,
)..where((t) => t.version.equals('generated'))).get();
for (final t in generatedTests) {
await (db.delete(db.tests)..where((x) => x.id.equals(t.id))).go();
}
if (packId != null) {
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!)))
.go();
await (db.delete(
db.cardPacks,
)..where((p) => p.id.equals(packId!))).go();
}
if (cardId != null) {
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId!)))
.go();
await (db.delete(
db.gameCards,
)..where((c) => c.id.equals(cardId!))).go();
}
packId = null;
@ -121,18 +124,20 @@ void main() {
await testManager.updateGeneratedTests(packModel);
final packTests = await db.testDao.getTestsByPackId(packId!);
final generated =
packTests.where((t) => t.version == 'generated').toList();
final generated = packTests
.where((t) => t.version == 'generated')
.toList();
expect(generated, hasLength(1));
final linkedPackId =
await db.testDao.getPackIdForTest(generated.single.id);
final linkedPackId = await db.testDao.getPackIdForTest(
generated.single.id,
);
expect(linkedPackId, equals(packId));
final relations = await (db.select(db.testPackRelations)
..where((r) => r.packId.equals(packId!)))
.get();
final relations = await (db.select(
db.testPackRelations,
)..where((r) => r.packId.equals(packId!))).get();
expect(relations, hasLength(1));
expect(relations.single.testId, equals(generated.single.id));
});

View file

@ -7,7 +7,7 @@ import 'package:mnemo_cards_backend/database/tables/packs.dart';
import 'package:mnemo_cards_backend/database/tables/relations.dart';
/// Unit тесты для WordStatisticsDao
///
///
/// Требования:
/// - PostgreSQL должен быть запущен на localhost:5432
/// - Тестовая БД: mnemo_cards_test
@ -22,7 +22,8 @@ void main() {
// Подключение к тестовой БД
// Можно использовать переменные окружения для настройки
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
@ -51,9 +52,7 @@ void main() {
// 2. Создать UserData
await db.userDao.createUserData(
UserDatasCompanion.insert(
userId: testUserId,
),
UserDatasCompanion.insert(userId: testUserId),
);
// 3. Создать пак
@ -194,14 +193,14 @@ void main() {
);
// Soft delete
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final result = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
@ -389,7 +388,8 @@ void main() {
// Создать другого пользователя
final user2 = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
externalUserId:
'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User 2'),
),
);
@ -444,14 +444,14 @@ void main() {
);
// Удалить одну запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats1.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats1.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// selectActive должен вернуть только активную запись
final activeStats = await db.wordStatisticsDao.selectActive().get();
@ -468,14 +468,14 @@ void main() {
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final result = await db.wordStatisticsDao.getActiveById(stats.id);
expect(result, isNull);

View file

@ -6,13 +6,16 @@ void main() {
test('AppDatabase can be instantiated', () {
// This test verifies that the database class can be created
// Actual connection will be tested with real PostgreSQL instance
expect(() => AppDatabase.connect(
host: 'localhost',
port: 5432,
database: 'test_db',
username: 'test_user',
password: 'test_pass',
), returnsNormally);
expect(
() => AppDatabase.connect(
host: 'localhost',
port: 5432,
database: 'test_db',
username: 'test_user',
password: 'test_pass',
),
returnsNormally,
);
});
test('All tables are registered', () {
@ -24,7 +27,7 @@ void main() {
username: 'test_user',
password: 'test_pass',
);
// Check that DAOs are available
expect(db.userDao, isNotNull);
expect(db.packDao, isNotNull);

View file

@ -256,11 +256,15 @@ void main() {
expect(unlocked.unlockedAt, isNotNull);
expect(
unlocked.unlockedAt!.isAfter(beforeUnlock.subtract(const Duration(seconds: 1))),
unlocked.unlockedAt!.isAfter(
beforeUnlock.subtract(const Duration(seconds: 1)),
),
isTrue,
);
expect(
unlocked.unlockedAt!.isBefore(afterUnlock.add(const Duration(seconds: 1))),
unlocked.unlockedAt!.isBefore(
afterUnlock.add(const Duration(seconds: 1)),
),
isTrue,
);
});
@ -526,11 +530,7 @@ void main() {
group('edge cases', () {
test('handles empty strings', () {
final model = AchievementModel(
id: '',
title: '',
description: '',
);
final model = AchievementModel(id: '', title: '', description: '');
expect(model.id, '');
expect(model.title, '');

View file

@ -83,10 +83,7 @@ void main() {
});
test('converts model with null dates to DTO', () {
final model = PackProgressModel(
packId: 'test_pack',
totalCards: 10,
);
final model = PackProgressModel(packId: 'test_pack', totalCards: 10);
final dto = model.toDto();
@ -134,10 +131,7 @@ void main() {
});
test('creates model from DTO with null dates', () {
final dto = PackProgressDto(
packId: 'test_pack',
totalCards: 10,
);
final dto = PackProgressDto(packId: 'test_pack', totalCards: 10);
final model = PackProgressModel.fromDto(dto);
@ -208,10 +202,7 @@ void main() {
});
test('adds new card to attempts map', () {
final model = PackProgressModel(
packId: 'test_pack',
totalCards: 10,
);
final model = PackProgressModel(packId: 'test_pack', totalCards: 10);
final updated = model.updateCardProgress(
cardId: 'new_card',
@ -238,9 +229,7 @@ void main() {
});
test('sets lastStudyDate to current time', () {
final model = PackProgressModel(
packId: 'test_pack',
);
final model = PackProgressModel(packId: 'test_pack');
final beforeUpdate = DateTime.now();
final updated = model.updateCardProgress(
@ -252,19 +241,21 @@ void main() {
expect(updated.lastStudyDate, isNotNull);
expect(
updated.lastStudyDate!.isAfter(beforeUpdate.subtract(const Duration(seconds: 1))),
updated.lastStudyDate!.isAfter(
beforeUpdate.subtract(const Duration(seconds: 1)),
),
isTrue,
);
expect(
updated.lastStudyDate!.isBefore(afterUpdate.add(const Duration(seconds: 1))),
updated.lastStudyDate!.isBefore(
afterUpdate.add(const Duration(seconds: 1)),
),
isTrue,
);
});
test('sets firstStudyDate if null', () {
final model = PackProgressModel(
packId: 'test_pack',
);
final model = PackProgressModel(packId: 'test_pack');
final updated = model.updateCardProgress(
cardId: 'card1',
@ -312,10 +303,7 @@ void main() {
});
test('returns true when both learnedCards and studyTimeMinutes > 0', () {
final model = PackProgressModel(
learnedCards: 5,
studyTimeMinutes: 10,
);
final model = PackProgressModel(learnedCards: 5, studyTimeMinutes: 10);
expect(model.hasStarted, isTrue);
});
@ -335,37 +323,25 @@ void main() {
});
test('calculates progress correctly', () {
final model = PackProgressModel(
totalCards: 10,
learnedCards: 5,
);
final model = PackProgressModel(totalCards: 10, learnedCards: 5);
expect(model.progress, 0.5);
});
test('calculates progress for partial completion', () {
final model = PackProgressModel(
totalCards: 100,
learnedCards: 33,
);
final model = PackProgressModel(totalCards: 100, learnedCards: 33);
expect(model.progress, 0.33);
});
test('returns 1.0 when all cards learned', () {
final model = PackProgressModel(
totalCards: 10,
learnedCards: 10,
);
final model = PackProgressModel(totalCards: 10, learnedCards: 10);
expect(model.progress, 1.0);
});
test('handles learnedCards exceeding totalCards', () {
final model = PackProgressModel(
totalCards: 10,
learnedCards: 15,
);
final model = PackProgressModel(totalCards: 10, learnedCards: 15);
expect(model.progress, 1.5);
});

View file

@ -8,10 +8,7 @@ void main() {
test('creates model with required parameters', () {
final startTime = DateTime.now();
final model = StudySessionModel(
userId: 1,
startTime: startTime,
);
final model = StudySessionModel(userId: 1, startTime: startTime);
expect(model.userId, 1);
expect(model.startTime, startTime);
@ -54,10 +51,7 @@ void main() {
test('creates model with default values', () {
final startTime = DateTime.now();
final model = StudySessionModel(
userId: 1,
startTime: startTime,
);
final model = StudySessionModel(userId: 1, startTime: startTime);
expect(model.wordsLearned, 0);
expect(model.testsCompleted, 0);
@ -97,10 +91,7 @@ void main() {
test('converts model with null values to DTO', () {
final startTime = DateTime.now();
final model = StudySessionModel(
userId: 1,
startTime: startTime,
);
final model = StudySessionModel(userId: 1, startTime: startTime);
final dto = model.toDto();
@ -143,9 +134,7 @@ void main() {
test('creates model from DTO with null values', () {
final startTime = DateTime.now();
final dto = StudySessionDto(
startTime: startTime,
);
final dto = StudySessionDto(startTime: startTime);
final model = StudySessionModel.fromDto(dto, 1);
@ -190,10 +179,7 @@ void main() {
group('isActive', () {
test('returns true when endTime is null', () {
final model = StudySessionModel(
userId: 1,
startTime: DateTime.now(),
);
final model = StudySessionModel(userId: 1, startTime: DateTime.now());
expect(model.isActive, isTrue);
});
@ -227,10 +213,7 @@ void main() {
test('calculates duration using current time when active', () {
final startTime = DateTime.now().subtract(const Duration(minutes: 15));
final model = StudySessionModel(
userId: 1,
startTime: startTime,
);
final model = StudySessionModel(userId: 1, startTime: startTime);
final duration = model.duration;
expect(duration.inMinutes, greaterThanOrEqualTo(14));
@ -287,10 +270,7 @@ void main() {
});
test('returns General Study when neither is set', () {
final model = StudySessionModel(
userId: 1,
startTime: DateTime.now(),
);
final model = StudySessionModel(userId: 1, startTime: DateTime.now());
expect(model.sessionType, 'General Study');
});
@ -416,10 +396,7 @@ void main() {
test('sets endTime to current time', () {
final startTime = DateTime.now().subtract(const Duration(minutes: 10));
final model = StudySessionModel(
userId: 1,
startTime: startTime,
);
final model = StudySessionModel(userId: 1, startTime: startTime);
final beforeEnd = DateTime.now();
final ended = model.end(
@ -431,7 +408,9 @@ void main() {
expect(ended.endTime, isNotNull);
expect(
ended.endTime!.isAfter(beforeEnd.subtract(const Duration(seconds: 1))),
ended.endTime!.isAfter(
beforeEnd.subtract(const Duration(seconds: 1)),
),
isTrue,
);
expect(
@ -441,10 +420,7 @@ void main() {
});
test('updates statistics correctly', () {
final model = StudySessionModel(
userId: 1,
startTime: DateTime.now(),
);
final model = StudySessionModel(userId: 1, startTime: DateTime.now());
final ended = model.end(
wordsLearned: 15,

View file

@ -103,7 +103,7 @@ void main() {
final json = user.toJson();
final deserialized = UserModel.fromJson(json);
expect(deserialized.email, equals('user@example.com'));
expect(deserialized.telegram, equals('@testuser'));
expect(deserialized.admin, isTrue);

View file

@ -8,15 +8,21 @@ void main() {
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5n5p0AAAAASUVORK5CYII=';
group('CardImageStorage', () {
test('sanitizeCardsFileName strips cards/ prefix and rejects traversal', () {
expect(CardImageStorage.sanitizeCardsFileName('cards/a.png'), 'a.png');
expect(CardImageStorage.sanitizeCardsFileName('/cards/a.png'), 'a.png');
expect(CardImageStorage.sanitizeCardsFileName('a.png'), 'a.png');
test(
'sanitizeCardsFileName strips cards/ prefix and rejects traversal',
() {
expect(CardImageStorage.sanitizeCardsFileName('cards/a.png'), 'a.png');
expect(CardImageStorage.sanitizeCardsFileName('/cards/a.png'), 'a.png');
expect(CardImageStorage.sanitizeCardsFileName('a.png'), 'a.png');
expect(CardImageStorage.sanitizeCardsFileName('../a.png'), isNull);
expect(CardImageStorage.sanitizeCardsFileName('cards/../a.png'), isNull);
expect(CardImageStorage.sanitizeCardsFileName('cards/a/b.png'), isNull);
});
expect(CardImageStorage.sanitizeCardsFileName('../a.png'), isNull);
expect(
CardImageStorage.sanitizeCardsFileName('cards/../a.png'),
isNull,
);
expect(CardImageStorage.sanitizeCardsFileName('cards/a/b.png'), isNull);
},
);
test('tryParseBase64Image parses data: url and detects png', () {
final parsed = CardImageStorage.tryParseBase64Image(
@ -64,4 +70,3 @@ void main() {
});
});
}

View file

@ -7,15 +7,18 @@ import 'package:test/test.dart';
void main() {
group('VoiceStorage', () {
test('sanitizeVoiceFileName strips voice/ prefix and rejects traversal', () {
expect(VoiceStorage.sanitizeVoiceFileName('voice/a.mp3'), 'a.mp3');
expect(VoiceStorage.sanitizeVoiceFileName('/voice/a.mp3'), 'a.mp3');
expect(VoiceStorage.sanitizeVoiceFileName('a.mp3'), 'a.mp3');
test(
'sanitizeVoiceFileName strips voice/ prefix and rejects traversal',
() {
expect(VoiceStorage.sanitizeVoiceFileName('voice/a.mp3'), 'a.mp3');
expect(VoiceStorage.sanitizeVoiceFileName('/voice/a.mp3'), 'a.mp3');
expect(VoiceStorage.sanitizeVoiceFileName('a.mp3'), 'a.mp3');
expect(VoiceStorage.sanitizeVoiceFileName('../a.mp3'), isNull);
expect(VoiceStorage.sanitizeVoiceFileName('voice/../a.mp3'), isNull);
expect(VoiceStorage.sanitizeVoiceFileName('voice/a/b.mp3'), isNull);
});
expect(VoiceStorage.sanitizeVoiceFileName('../a.mp3'), isNull);
expect(VoiceStorage.sanitizeVoiceFileName('voice/../a.mp3'), isNull);
expect(VoiceStorage.sanitizeVoiceFileName('voice/a/b.mp3'), isNull);
},
);
test('tryParseBase64Audio parses data: url and detects mp3', () {
final bytes = Uint8List.fromList([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]);
@ -64,4 +67,3 @@ void main() {
});
});
}

View file

@ -7,13 +7,13 @@ import 'package:mnemo_cards_backend/statistics/word_statistics_manager.dart';
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
/// Smoke тесты для проверки базовой функциональности после деплоя
///
///
/// Эти тесты проверяют:
/// - Создание БД и таблиц
/// - Основные CRUD операции
/// - Интеграцию WordStatisticsManager с TestManager
/// - Расчет статистики через StatisticsCalculator
///
///
/// Требования:
/// - PostgreSQL должен быть запущен
/// - Тестовая БД: mnemo_cards_test
@ -28,7 +28,8 @@ void main() {
setUpAll(() async {
// Подключение к тестовой БД
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
@ -47,7 +48,8 @@ void main() {
// Создать тестовые данные
final user = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'smoke_test_user_${DateTime.now().millisecondsSinceEpoch}',
externalUserId:
'smoke_test_user_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Smoke Test User'),
email: Value('smoke@test.com'),
),
@ -156,7 +158,10 @@ void main() {
expect(packProgress.packId, equals(testPackId));
expect(packProgress.totalCards, equals(10));
expect(packProgress.learnedCards, equals(2)); // 2 карточки с ответами
expect(packProgress.averageAccuracy, closeTo(0.5, 0.01)); // 1 правильный, 1 неправильный
expect(
packProgress.averageAccuracy,
closeTo(0.5, 0.01),
); // 1 правильный, 1 неправильный
});
test('studyDates рассчитываются из StudySessions', () async {
@ -172,16 +177,20 @@ void main() {
),
);
final studyDates = await statisticsCalculator.calculateStudyDates(testUserId);
final studyDates = await statisticsCalculator.calculateStudyDates(
testUserId,
);
expect(studyDates, isNotEmpty);
// Проверить что дата сегодняшнего дня присутствует
final today = DateTime(now.year, now.month, now.day);
expect(
studyDates.any((d) =>
d.year == today.year &&
d.month == today.month &&
d.day == today.day),
studyDates.any(
(d) =>
d.year == today.year &&
d.month == today.month &&
d.day == today.day,
),
isTrue,
);
});
@ -204,18 +213,22 @@ void main() {
userId: testUserId,
packId: testPackId,
startTime: PgDateTime(now.add(const Duration(hours: 1))),
endTime: Value(PgDateTime(now.add(const Duration(hours: 1, minutes: 20)))),
endTime: Value(
PgDateTime(now.add(const Duration(hours: 1, minutes: 20))),
),
durationMinutes: 20,
),
);
final categoryMinutes = await statisticsCalculator.calculateCategoryMinutes(
testUserId,
);
final categoryMinutes = await statisticsCalculator
.calculateCategoryMinutes(testUserId);
expect(categoryMinutes, isNotEmpty);
// Должно быть минимум 35 минут (15 + 20)
final totalMinutes = categoryMinutes.values.fold<int>(0, (sum, minutes) => sum + minutes);
final totalMinutes = categoryMinutes.values.fold<int>(
0,
(sum, minutes) => sum + minutes,
);
expect(totalMinutes, greaterThanOrEqualTo(35));
});
@ -229,14 +242,14 @@ void main() {
);
// Soft delete
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
await (db.update(
db.wordStatistics,
)..where((w) => w.id.equals(stats.id))).write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// Проверить что запись не возвращается через getActiveById
final activeRecord = await db.wordStatisticsDao.getActiveById(stats.id);

View file

@ -34,7 +34,10 @@ void main() {
expect(sampleAchievementIds.length, equals(19));
expect(sampleAchievementIds.every((id) => id.isNotEmpty), isTrue);
expect(sampleAchievementIds.toSet().length, equals(sampleAchievementIds.length)); // All unique
expect(
sampleAchievementIds.toSet().length,
equals(sampleAchievementIds.length),
); // All unique
});
test('Achievement categories are properly defined', () {
@ -96,8 +99,19 @@ void main() {
test('Progressive achievements have increasing difficulty', () {
// Test that later achievements require more progress
const wordAchievements = ['words_10', 'words_50', 'words_100', 'words_500', 'words_1000'];
const streakAchievements = ['streak_3', 'streak_7', 'streak_30', 'streak_100'];
const wordAchievements = [
'words_10',
'words_50',
'words_100',
'words_500',
'words_1000',
];
const streakAchievements = [
'streak_3',
'streak_7',
'streak_30',
'streak_100',
];
expect(wordAchievements.length, equals(5));
expect(streakAchievements.length, equals(4));
@ -136,11 +150,11 @@ void main() {
final progress1 = calculateProgressMap(5, 2);
expect(progress1['words_10'], equals(0.5));
expect(progress1['streak_3'], equals(2/3));
expect(progress1['streak_3'], equals(2 / 3));
final progress2 = calculateProgressMap(25, 5);
expect(progress2['words_50'], equals(0.5));
expect(progress2['streak_7'], equals(5/7));
expect(progress2['streak_7'], equals(5 / 7));
});
test('Achievement progress is properly bounded', () {

View file

@ -34,12 +34,14 @@ class MockIsar extends Fake implements Isar {
}
@override
QueryBuilder<StudySessionModel, StudySessionModel, QWhereClause> get studySessionModels {
QueryBuilder<StudySessionModel, StudySessionModel, QWhereClause>
get studySessionModels {
return MockQueryBuilder(_sessions);
}
@override
QueryBuilder<AchievementModel, AchievementModel, QWhereClause> get achievementModels {
QueryBuilder<AchievementModel, AchievementModel, QWhereClause>
get achievementModels {
return MockQueryBuilder([]);
}
@ -133,18 +135,12 @@ void main() {
testId: 'integration_test',
sessionToken: 'test_session_${DateTime.now().millisecondsSinceEpoch}',
packId: 'test_pack',
words: TestWordsDto(words: [
TestWordDto(
word: 'hello',
correct: 1,
incorrect: 0,
),
TestWordDto(
word: 'world',
correct: 1,
incorrect: 0,
),
]),
words: TestWordsDto(
words: [
TestWordDto(word: 'hello', correct: 1, incorrect: 0),
TestWordDto(word: 'world', correct: 1, incorrect: 0),
],
),
);
// Simulate the addTestStatistics call (without the actual database save)
@ -208,21 +204,25 @@ void main() {
testId: 'test1',
sessionToken: 'session1',
packId: 'pack1',
words: TestWordsDto(words: [
TestWordDto(word: 'word1', correct: 1, incorrect: 0),
TestWordDto(word: 'word2', correct: 0, incorrect: 1),
]),
words: TestWordsDto(
words: [
TestWordDto(word: 'word1', correct: 1, incorrect: 0),
TestWordDto(word: 'word2', correct: 0, incorrect: 1),
],
),
);
final testStats2 = TestStatisticsDto(
testId: 'test2',
sessionToken: 'session2',
packId: 'pack1',
words: TestWordsDto(words: [
TestWordDto(word: 'word3', correct: 1, incorrect: 0),
TestWordDto(word: 'word4', correct: 1, incorrect: 0),
TestWordDto(word: 'word5', correct: 0, incorrect: 1),
]),
words: TestWordsDto(
words: [
TestWordDto(word: 'word3', correct: 1, incorrect: 0),
TestWordDto(word: 'word4', correct: 1, incorrect: 0),
TestWordDto(word: 'word5', correct: 0, incorrect: 1),
],
),
);
// Test session tracking across multiple tests
@ -233,7 +233,9 @@ void main() {
testsCompleted: 1,
);
final sessionId2 = await sessionTracker.getOrCreateSession(testUser.id!); // Should reuse session
final sessionId2 = await sessionTracker.getOrCreateSession(
testUser.id!,
); // Should reuse session
expect(sessionId2, equals(sessionId1));
await sessionTracker.updateSessionProgress(
@ -256,7 +258,10 @@ void main() {
];
final updatedUserData = testUser.userData.value!.copyWith(words: words);
final progress = await achievementManager.getAchievementProgress(testUser.id!, updatedUserData);
final progress = await achievementManager.getAchievementProgress(
testUser.id!,
updatedUserData,
);
// Should show progress towards word achievements
expect(progress['words_10'], equals(0.5)); // 5/10 = 0.5
@ -290,7 +295,10 @@ void main() {
testUser.userData.value!,
);
expect(newlyUnlocked, isEmpty); // No achievements should unlock with empty data
expect(
newlyUnlocked,
isEmpty,
); // No achievements should unlock with empty data
// Test session tracking with non-existent user
final sessionId = await sessionTracker.getOrCreateSession(999);
@ -365,11 +373,11 @@ void main() {
// Add some words
final updatedUserData1 = testUser.userData.value!.copyWith(
words: List.generate(6, (i) => WordStatisticsModel(
word: 'word$i',
correct: 1.0,
incorrect: 0.0,
)),
words: List.generate(
6,
(i) =>
WordStatisticsModel(word: 'word$i', correct: 1.0, incorrect: 0.0),
),
currentStreak: 2,
);
@ -379,15 +387,15 @@ void main() {
);
expect(progress1['words_10'], equals(0.6)); // 6/10 = 0.6
expect(progress1['streak_3'], equals(2/3)); // 2/3 0.67
expect(progress1['streak_3'], equals(2 / 3)); // 2/3 0.67
// Add more progress
final updatedUserData2 = updatedUserData1.copyWith(
words: List.generate(8, (i) => WordStatisticsModel(
word: 'word$i',
correct: 1.0,
incorrect: 0.0,
)),
words: List.generate(
8,
(i) =>
WordStatisticsModel(word: 'word$i', correct: 1.0, incorrect: 0.0),
),
currentStreak: 3,
);
@ -397,7 +405,10 @@ void main() {
);
expect(progress2['words_10'], equals(0.8)); // 8/10 = 0.8
expect(progress2['streak_3'], equals(1.0)); // 3/3 = 1.0 (achievement unlocked)
expect(
progress2['streak_3'],
equals(1.0),
); // 3/3 = 1.0 (achievement unlocked)
});
});
}

View file

@ -79,7 +79,10 @@ void main() {
// 3. Session is tracked
// 4. Achievements are checked and unlocked
expect(true, isTrue); // Integration tests would be implemented with proper mocking
expect(
true,
isTrue,
); // Integration tests would be implemented with proper mocking
});
test('Error handling prevents system crashes', () {

View file

@ -50,7 +50,11 @@ void main() {
final yesterday = today.subtract(const Duration(days: 1));
final threeDaysAgo = today.subtract(const Duration(days: 3));
final result = calculator.calculateStreak([today, yesterday, threeDaysAgo]);
final result = calculator.calculateStreak([
today,
yesterday,
threeDaysAgo,
]);
expect(result, 2); // Today and yesterday, gap on day 3
});
@ -63,9 +67,15 @@ void main() {
test('handles dates with different times', () {
final todayMorning = DateTime.now().copyWith(hour: 9);
final todayEvening = DateTime.now().copyWith(hour: 20);
final yesterday = DateTime.now().subtract(const Duration(days: 1, hours: 10));
final yesterday = DateTime.now().subtract(
const Duration(days: 1, hours: 10),
);
final result = calculator.calculateStreak([todayMorning, todayEvening, yesterday]);
final result = calculator.calculateStreak([
todayMorning,
todayEvening,
yesterday,
]);
expect(result, 2);
});
});
@ -122,9 +132,7 @@ void main() {
studyTimeMinutes: 45,
);
final userData = UserDataModel(
packProgress: [pack1, pack2],
);
final userData = UserDataModel(packProgress: [pack1, pack2]);
final result = calculator.calculateTotalStudyTime(userData);
expect(result, 75); // 30 + 45
@ -224,10 +232,7 @@ void main() {
],
);
final result = calculator.getTimelineStatistics(
userData,
period: 'week',
);
final result = calculator.getTimelineStatistics(userData, period: 'week');
expect(result['period'], 'week');
expect(result.containsKey('totalDays'), true);
@ -247,10 +252,17 @@ void main() {
test('calculates level based on activity', () {
final userData = UserDataModel(
words: List.generate(20, (i) => WordStatisticsModel(word: 'word$i')), // 20 words
words: List.generate(
20,
(i) => WordStatisticsModel(word: 'word$i'),
), // 20 words
totalStudyTimeMinutes: 60 * 10, // 10 hours = 600 minutes
packProgress: [
PackProgressModel(packId: 'pack1', totalCards: 10, learnedCards: 10), // 1 completed pack
PackProgressModel(
packId: 'pack1',
totalCards: 10,
learnedCards: 10,
), // 1 completed pack
],
);

View file

@ -7,7 +7,7 @@ import 'package:mnemo_cards_backend/database/tables/users.dart';
import 'package:mnemo_cards_backend/database/tables/packs.dart';
/// Unit тесты для WordStatisticsManager
///
///
/// Требования:
/// - PostgreSQL должен быть запущен на localhost:5432
/// - Тестовая БД: mnemo_cards_test
@ -20,7 +20,8 @@ void main() {
setUpAll(() async {
// Подключение к тестовой БД
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
@ -242,10 +243,7 @@ void main() {
isCorrect: true,
);
final packStats = await manager.getPackStatistics(
testUserId,
pack.id,
);
final packStats = await manager.getPackStatistics(testUserId, pack.id);
expect(packStats.length, equals(2));
expect(
@ -263,10 +261,7 @@ void main() {
),
);
final packStats = await manager.getPackStatistics(
testUserId,
pack.id,
);
final packStats = await manager.getPackStatistics(testUserId, pack.id);
expect(packStats, isEmpty);
});

View file

@ -4,14 +4,8 @@ import 'package:mnemo_cards_backend/storage/minio_config.dart';
void main() {
group('MinioConfig', () {
test('should have correct bucket names', () {
expect(
MinioConfig.cardImagesBucket,
equals('card-images'),
);
expect(
MinioConfig.voiceAudioBucket,
equals('voice-audio'),
);
expect(MinioConfig.cardImagesBucket, equals('card-images'));
expect(MinioConfig.voiceAudioBucket, equals('voice-audio'));
});
test('should have correct presigned URL expiry', () {
@ -34,7 +28,7 @@ void main() {
// Test that bucket names are correctly defined
expect(MinioConfig.cardImagesBucket, isNotEmpty);
expect(MinioConfig.voiceAudioBucket, isNotEmpty);
// Test that bucket names are different
expect(
MinioConfig.cardImagesBucket,
@ -44,14 +38,8 @@ void main() {
test('should have reasonable presigned URL expiry', () {
// Presigned URLs should expire after 4 hours (14400 seconds)
expect(
MinioConfig.presignedUrlExpirySeconds,
equals(14400),
);
expect(
MinioConfig.presignedUrlExpirySeconds,
greaterThan(0),
);
expect(MinioConfig.presignedUrlExpirySeconds, equals(14400));
expect(MinioConfig.presignedUrlExpirySeconds, greaterThan(0));
});
});
}

View file

@ -46,9 +46,7 @@ void main() {
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_translation,
},
possibleTypes: {SimpleQuestionType.audio_translation},
);
final answerCard = data.items.first; // card_1 with UUID audio
@ -82,9 +80,7 @@ void main() {
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_translation,
},
possibleTypes: {SimpleQuestionType.audio_translation},
);
final answerCard = data.items.first;
@ -116,9 +112,7 @@ void main() {
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_translation,
},
possibleTypes: {SimpleQuestionType.audio_translation},
);
final answerCard = data.items.first;
@ -151,9 +145,7 @@ void main() {
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_images,
},
possibleTypes: {SimpleQuestionType.audio_images},
);
final answerCard = data.items.first;
@ -167,4 +159,4 @@ void main() {
expect(sq.text, isNull); // No text for audio questions
});
});
}
}

View file

@ -17,12 +17,13 @@ void main() {
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database =
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ??
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username =
Platform.environment['TEST_DB_USER'] ??
Platform.environment['DB_USER'] ??
'mnemo_user';
final password = Platform.environment['TEST_DB_PASSWORD'] ??
final password =
Platform.environment['TEST_DB_PASSWORD'] ??
Platform.environment['DB_PASSWORD'] ??
'';
@ -38,13 +39,13 @@ void main() {
await Migrator(db).createAll();
// Initialize MinioService
final minioEndpoint = Platform.environment['MINIO_ENDPOINT'] ??
'localhost:9000';
final minioAccessKey = Platform.environment['MINIO_ACCESS_KEY'] ??
'minioadmin';
final minioSecretKey = Platform.environment['MINIO_SECRET_KEY'] ??
'minioadmin';
final minioEndpoint =
Platform.environment['MINIO_ENDPOINT'] ?? 'localhost:9000';
final minioAccessKey =
Platform.environment['MINIO_ACCESS_KEY'] ?? 'minioadmin';
final minioSecretKey =
Platform.environment['MINIO_SECRET_KEY'] ?? 'minioadmin';
minioService = MinioService(
endpoint: minioEndpoint,
accessKey: minioAccessKey,
@ -71,99 +72,100 @@ void main() {
testId = null;
}
if (packId != null) {
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!)))
.go();
await (db.delete(
db.cardPacks,
)..where((p) => p.id.equals(packId!))).go();
packId = null;
}
if (cardId != null) {
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId!)))
.go();
await (db.delete(
db.gameCards,
)..where((c) => c.id.equals(cardId!))).go();
cardId = null;
}
});
test('matrix question buttons should have imageUrl from card images',
() async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'subtitle',
size: 1,
),
);
// Create card with image (using UUID as objectId in MinIO)
const imageObjectId = '12345678-1234-1234-1234-123456789abc';
cardId = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'perro',
translation: 'dog',
image: imageObjectId,
),
);
await db.packDao.addCardToPack(
packId: packId!,
cardId: cardId!,
order: 0,
);
// Create test with matrix question (without buttons - they'll be generated)
testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'Matrix Test',
color: const Value('#ff0000'),
version: const Value('1.0'),
),
);
await db.testDao.linkTestToPack(testId!, packId!);
// Create matrix question without buttons
await db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId!,
orderIndex: const Value(0),
questionType: TestQuestionType.matrix.name,
word: 'test',
answer: '',
options: const Value('[]'), // Empty buttons - will be generated
uiData: const Value('{"matrixSize": 1}'),
),
);
// Fetch test
final user = UserModel(id: 'test-user');
final result = await testManager.fetchTest(testId!, user);
expect(result, isNotNull);
expect(result!.questions, hasLength(1));
final question = result.questions.first;
expect(question.questionType, equals(TestQuestionType.matrix));
// Check buttons
if (question is MatrixTestQuestion) {
expect(question.buttons, hasLength(1));
final button = question.buttons.first;
expect(button.id, equals(cardId));
expect(button.image, equals(imageObjectId));
expect(button.imageUrl, isNotNull);
expect(button.imageUrl, isNot(equals(imageObjectId)));
// imageUrl should be a presigned URL or API endpoint
expect(
button.imageUrl,
anyOf(
startsWith('http'),
startsWith('/api/v2/packs'),
test(
'matrix question buttons should have imageUrl from card images',
() async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'subtitle',
size: 1,
),
);
} else {
fail('Expected MatrixTestQuestion');
}
});
// Create card with image (using UUID as objectId in MinIO)
const imageObjectId = '12345678-1234-1234-1234-123456789abc';
cardId = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'perro',
translation: 'dog',
image: imageObjectId,
),
);
await db.packDao.addCardToPack(
packId: packId!,
cardId: cardId!,
order: 0,
);
// Create test with matrix question (without buttons - they'll be generated)
testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'Matrix Test',
color: const Value('#ff0000'),
version: const Value('1.0'),
),
);
await db.testDao.linkTestToPack(testId!, packId!);
// Create matrix question without buttons
await db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId!,
orderIndex: const Value(0),
questionType: TestQuestionType.matrix.name,
word: 'test',
answer: '',
options: const Value('[]'), // Empty buttons - will be generated
uiData: const Value('{"matrixSize": 1}'),
),
);
// Fetch test
final user = UserModel(id: 'test-user');
final result = await testManager.fetchTest(testId!, user);
expect(result, isNotNull);
expect(result!.questions, hasLength(1));
final question = result.questions.first;
expect(question.questionType, equals(TestQuestionType.matrix));
// Check buttons
if (question is MatrixTestQuestion) {
expect(question.buttons, hasLength(1));
final button = question.buttons.first;
expect(button.id, equals(cardId));
expect(button.image, equals(imageObjectId));
expect(button.imageUrl, isNotNull);
expect(button.imageUrl, isNot(equals(imageObjectId)));
// imageUrl should be a presigned URL or API endpoint
expect(
button.imageUrl,
anyOf(startsWith('http'), startsWith('/api/v2/packs')),
);
} else {
fail('Expected MatrixTestQuestion');
}
},
);
test('test question with image should have imageUrl', () async {
// Create pack
@ -202,7 +204,7 @@ void main() {
await db.testDao.linkTestToPack(testId!, packId!);
const imageObjectId = '87654321-4321-4321-4321-cba987654321';
// Create question with image
await db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
@ -222,10 +224,10 @@ void main() {
expect(result, isNotNull);
expect(result!.questions, hasLength(1));
final question = result.questions.first;
expect(question.questionType, equals(TestQuestionType.input_buttons));
// Check image and imageUrl
if (question is InputButtonsTestQuestion) {
expect(question.image, equals(imageObjectId));
@ -264,7 +266,7 @@ void main() {
);
const coverObjectId = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
// Create test with cover
testId = await db.testDao.createTest(
TestsCompanion.insert(

View file

@ -13,9 +13,7 @@ void main() async {
username: 'mnemo_user',
password: 'dev_password_change_me',
),
settings: pg.ConnectionSettings(
sslMode: pg.SslMode.disable,
),
settings: pg.ConnectionSettings(sslMode: pg.SslMode.disable),
);
print('✅ PostgreSQL connection successful');
@ -27,9 +25,8 @@ void main() async {
await connection.close();
print('✅ Database connection closed');
print('🎉 PostgreSQL backend can connect to database!');
} catch (e, s) {
print('❌ Error: $e');
print('Stack trace: $s');
}
}
}

View file

@ -55,3 +55,4 @@ echo " - favicon.png (32x32)"